🎖️GitЯра🎖️
Commit e9d09a3380eca7860f1b8dfbd1939d8415fc0e33
Parents : ea97c36
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-15T21:44:17Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-15T21:44:17Z
fix(lifecycle): harden packet admission and transport ownership (#6716)
Changes
106 files changed, 10743 insertions(+), 1636 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 85d5f4815d..59667819e7 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -1237,6 +1237,7 @@ node_list_help_node_details
node_list_help_title
node_list_long_click_label
node_number
+node_request_send_failed
node_restarting
node_sort_alpha
node_sort_button
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
index 4110ba06d5..7b6f124881 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt
@@ -32,13 +32,18 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
+import org.meshtastic.core.repository.AwaitedSendResult
import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
+import org.meshtastic.core.repository.toFixedPositionAdminMessage
+import org.meshtastic.core.repository.toFixedPositionProto
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.AirQualityMetrics
import org.meshtastic.proto.ChannelSet
@@ -90,6 +95,8 @@ class CommandSenderImpl(
override fun getCurrentPacketId(): Long = currentPacketId.value
+ private fun Int.nonZeroRequestId(): Int = takeUnless { it == 0 } ?: generatePacketId()
+
override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
@@ -151,10 +158,15 @@ class CommandSenderImpl(
p.status = MessageStatus.QUEUED
}
- sendNow(p)
+ if (!sendNow(p)) {
+ p.status = MessageStatus.ERROR
+ // Persistence owners treat a normal return as successful admission; throw so they can requeue or fail it.
+ throw PacketQueueRejectedException("Data packet")
+ }
+ p.time = nowMillis
}
- private suspend fun sendNow(p: DataPacket) {
+ private suspend fun sendNow(p: DataPacket): Boolean {
val meshPacket =
buildMeshPacket(
to = resolveNodeNum(NodeAddress.fromString(p.to)),
@@ -170,15 +182,47 @@ class CommandSenderImpl(
emoji = p.emoji,
),
)
- p.time = nowMillis
- packetHandler.sendToRadio(meshPacket)
+ return packetHandler.sendToRadio(meshPacket)
}
+ private suspend fun enqueueOrThrow(packet: MeshPacket, operation: String, expectedConnectionVersion: Long? = null) {
+ val accepted =
+ if (expectedConnectionVersion == null) {
+ packetHandler.sendToRadio(packet)
+ } else {
+ packetHandler.sendToRadioForConnection(packet, expectedConnectionVersion)
+ }
+ if (!accepted) throw PacketQueueRejectedException(operation)
+ }
+
+ private fun buildAdminMessagePacket(
+ destNum: Int,
+ requestId: Int,
+ wantResponse: Boolean,
+ initFn: () -> AdminMessage,
+ ): MeshPacket = buildAdminPacket(
+ to = destNum,
+ id = requestId.nonZeroRequestId(),
+ wantResponse = wantResponse,
+ adminMessage = initFn().copy(session_passkey = sessionManager.getPasskey(destNum)),
+ )
+
override suspend fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
- val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
- val packet =
- buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
- packetHandler.sendToRadio(packet)
+ enqueueOrThrow(buildAdminMessagePacket(destNum, requestId, wantResponse, initFn), "Admin command")
+ }
+
+ override suspend fun sendAdminForConnection(
+ destNum: Int,
+ expectedConnectionVersion: Long,
+ requestId: Int,
+ wantResponse: Boolean,
+ initFn: () -> AdminMessage,
+ ) {
+ enqueueOrThrow(
+ buildAdminMessagePacket(destNum, requestId, wantResponse, initFn),
+ "Admin command",
+ expectedConnectionVersion,
+ )
}
override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {
@@ -187,28 +231,20 @@ class CommandSenderImpl(
packetHandler.sendToRadio(ToRadio(packet = packet))
}
- override suspend fun sendAdminAwait(
+ override suspend fun sendAdminAwaitResult(
destNum: Int,
requestId: Int,
wantResponse: Boolean,
initFn: () -> AdminMessage,
- ): Boolean {
- val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
- val packet =
- buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
- return packetHandler.sendToRadioAndAwait(packet)
- }
+ ): AwaitedSendResult =
+ packetHandler.sendToRadioAndAwaitResult(buildAdminMessagePacket(destNum, requestId, wantResponse, initFn))
override suspend fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
- val myNum = nodeManager.myNodeNum.value ?: return
+ val myNum = nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("Position update")
val idNum = destNum ?: myNum
- Logger.d { "Sending our position/time to=$idNum $pos" }
+ Logger.d { "Sending our position/time to=$idNum" }
- if (localConfig.value.position?.fixed_position != true) {
- nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
- }
-
- packetHandler.sendToRadio(
+ enqueueOrThrow(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else getChannelIndex(destNum),
@@ -220,7 +256,11 @@ class CommandSenderImpl(
want_response = wantResponse,
),
),
+ "Position update",
)
+ if (localConfig.value.position?.fixed_position != true) {
+ nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
+ }
}
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
@@ -231,7 +271,7 @@ class CommandSenderImpl(
altitude = currentPosition.altitude,
time = (nowMillis / 1000L).toInt(),
)
- packetHandler.sendToRadio(
+ enqueueOrThrow(
buildMeshPacket(
to = destNum,
channel = getChannelIndex(destNum),
@@ -243,30 +283,28 @@ class CommandSenderImpl(
want_response = true,
),
),
+ "Position request",
)
}
override suspend fun setFixedPosition(destNum: Int, pos: Position) {
- val meshPos =
- ProtoPosition(
- latitude_i = Position.degI(pos.latitude),
- longitude_i = Position.degI(pos.longitude),
- altitude = pos.altitude,
- )
- sendAdmin(destNum) {
- if (pos != Position(0.0, 0.0, 0)) {
- AdminMessage(set_fixed_position = meshPos)
+ val removesFixedPosition = pos.isFixedPositionRemoval()
+ val myNodeNum =
+ if (removesFixedPosition) {
+ null
} else {
- AdminMessage(remove_fixed_position = true)
+ nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("Fixed position")
}
+ sendAdmin(destNum) { pos.toFixedPositionAdminMessage() }
+ if (myNodeNum != null) {
+ nodeManager.handleReceivedPosition(destNum, myNodeNum, pos.toFixedPositionProto(), nowMillis)
}
- nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
}
override suspend fun requestUserInfo(destNum: Int) {
- val myNum = nodeManager.myNodeNum.value ?: return
- val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
- packetHandler.sendToRadio(
+ val myNum = nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("User-info request")
+ val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: throw LocalNodeUnavailableException("User-info request")
+ enqueueOrThrow(
buildMeshPacket(
to = destNum,
channel = getChannelIndex(destNum),
@@ -277,23 +315,45 @@ class CommandSenderImpl(
payload = myNode.user.encode().toByteString(),
),
),
+ "User-info request",
)
}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
- tracerouteHandler.recordStartTime(requestId)
- packetHandler.sendToRadio(
+ val effectiveRequestId = requestId.nonZeroRequestId()
+ enqueueOrThrow(
buildMeshPacket(
to = destNum,
wantAck = true,
- id = requestId,
+ id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
),
+ "Traceroute request",
)
+ tracerouteHandler.recordStartTime(effectiveRequestId)
}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
+ enqueueTelemetryOrThrow(requestId, destNum, typeValue)
+ }
+
+ override suspend fun requestTelemetryForConnection(
+ requestId: Int,
+ destNum: Int,
+ typeValue: Int,
+ expectedConnectionVersion: Long,
+ ) {
+ enqueueTelemetryOrThrow(requestId, destNum, typeValue, expectedConnectionVersion)
+ }
+
+ private suspend fun enqueueTelemetryOrThrow(
+ requestId: Int,
+ destNum: Int,
+ typeValue: Int,
+ expectedConnectionVersion: Long? = null,
+ ) {
+ val effectiveRequestId = requestId.nonZeroRequestId()
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
@@ -317,47 +377,49 @@ class CommandSenderImpl(
.toByteString()
}
- packetHandler.sendToRadio(
+ enqueueOrThrow(
buildMeshPacket(
to = destNum,
- id = requestId,
+ id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
),
+ "Telemetry request",
+ expectedConnectionVersion,
)
}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
- neighborInfoHandler.recordStartTime(requestId)
- val myNum = nodeManager.myNodeNum.value ?: 0
- if (destNum == myNum) {
- val neighborInfoToSend =
- neighborInfoHandler.lastNeighborInfo
- ?: run {
- val oneHour = 1.hours.inWholeMinutes.toInt()
- Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
- NeighborInfo(
- node_id = myNum,
- last_sent_by_id = myNum,
- node_broadcast_interval_secs = oneHour,
- neighbors =
- listOf(
- Neighbor(
- node_id = 0, // Dummy node ID that can be intercepted
- snr = 0f,
- last_rx_time = (nowMillis / 1000L).toInt(),
- node_broadcast_interval_secs = oneHour,
+ val effectiveRequestId = requestId.nonZeroRequestId()
+ val myNum = nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("Neighbor-info request")
+ val packet =
+ if (destNum == myNum) {
+ val neighborInfoToSend =
+ neighborInfoHandler.lastNeighborInfo
+ ?: run {
+ val oneHour = 1.hours.inWholeMinutes.toInt()
+ Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
+ NeighborInfo(
+ node_id = myNum,
+ last_sent_by_id = myNum,
+ node_broadcast_interval_secs = oneHour,
+ neighbors =
+ listOf(
+ Neighbor(
+ node_id = 0, // Dummy node ID that can be intercepted
+ snr = 0f,
+ last_rx_time = (nowMillis / 1000L).toInt(),
+ node_broadcast_interval_secs = oneHour,
+ ),
),
- ),
- )
- }
+ )
+ }
- // Send the neighbor info from our connected radio to ourselves (simulated)
- packetHandler.sendToRadio(
+ // Send the neighbor info from our connected radio to ourselves (simulated)
buildMeshPacket(
to = destNum,
wantAck = true,
- id = requestId,
+ id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded =
Data(
@@ -365,20 +427,19 @@ class CommandSenderImpl(
payload = neighborInfoToSend.encode().toByteString(),
want_response = true,
),
- ),
- )
- } else {
- // Send request to remote
- packetHandler.sendToRadio(
+ )
+ } else {
+ // Send request to remote
buildMeshPacket(
to = destNum,
wantAck = true,
- id = requestId,
+ id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
- ),
- )
- }
+ )
+ }
+ enqueueOrThrow(packet, "Neighbor-info request")
+ neighborInfoHandler.recordStartTime(effectiveRequestId)
}
override fun sendLockdownPassphrase(
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataPacketPersistence.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataPacketPersistence.kt
new file mode 100644
index 0000000000..24ed9d0268
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DataPacketPersistence.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.manager
+
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.PortNum
+
+/** Port numbers whose ordinary data packets are persisted by [MeshDataHandlerImpl.rememberDataPacket]. */
+internal val PERSISTED_DATA_PORT_NUMBERS =
+ setOf(
+ PortNum.TEXT_MESSAGE_APP.value,
+ PortNum.ALERT_APP.value,
+ PortNum.WAYPOINT_APP.value,
+ PortNum.NODE_STATUS_APP.value,
+ )
+
+/** A text-app payload that acknowledges another packet with an emoji reaction. */
+internal fun Data.isReaction(): Boolean = portnum == PortNum.TEXT_MESSAGE_APP && reply_id != 0 && emoji != 0
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index ab82836604..9a5629f73f 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -19,10 +19,13 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.model.util.anonymize
+import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
@@ -30,7 +33,11 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
@Single
-class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHandler: PacketHandler) : HistoryManager {
+class HistoryManagerImpl(
+ private val meshPrefs: MeshPrefs,
+ private val packetHandler: PacketHandler,
+ private val commandSender: CommandSender,
+) : HistoryManager {
companion object {
private const val HISTORY_TAG = "HistoryReplay"
@@ -61,9 +68,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
private val logger = Logger.withTag(HISTORY_TAG)
- private fun historyLog(message: String, throwable: Throwable? = null) {
- logger.i(throwable) { message }
- }
+ private fun historyLog(message: String) = logger.i { message }
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
@@ -73,13 +78,11 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
transport: String,
+ expectedConnectionVersion: Long,
) {
val address = activeDeviceAddress()
- if (address == null || myNodeNum == null) {
- val reason = if (address == null) "no_addr" else "no_my_node"
- historyLog("requestHistory skipped trigger=$trigger reason=$reason")
- return
- }
+ val nodeNum = myNodeNum
+ if (address == null || nodeNum == null) throw LocalNodeUnavailableException("History replay")
val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value
val (window, max) =
@@ -91,22 +94,22 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
historyLog(
- "requestHistory trigger=$trigger transport=$transport addr=$address " +
+ "requestHistory trigger=$trigger transport=$transport addr=${address.anonymize} " +
"lastRequest=$lastRequest window=$window max=$max",
)
- safeCatching {
- packetHandler.sendToRadio(
+ val accepted =
+ packetHandler.sendToRadioForConnection(
MeshPacket(
- from = myNodeNum,
- to = myNodeNum,
- id = kotlin.random.Random.nextInt(1, Int.MAX_VALUE),
+ from = nodeNum,
+ to = nodeNum,
+ id = commandSender.generatePacketId(),
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()),
priority = MeshPacket.Priority.BACKGROUND,
),
+ expectedConnectionVersion,
)
- }
- .onFailure { ex -> logger.w(ex) { "requestHistory failed" } }
+ if (!accepted) throw PacketQueueRejectedException("History replay")
}
override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
@@ -117,7 +120,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
historyLog(
"historyMarker updated source=$source transport=$transport " +
- "addr=$address from=$current to=$lastRequest",
+ "addr=${address.anonymize} from=$current to=$lastRequest",
)
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index 2cdcb9d721..d69221dd99 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -21,11 +21,13 @@ import co.touchlab.kermit.Severity
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
@@ -43,6 +45,7 @@ import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.LockdownCoordinator
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
@@ -53,6 +56,7 @@ import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
@@ -107,6 +111,9 @@ class MeshConnectionManagerImpl(
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
+ /** Guarded by [connectionMutex]. */
+ private var postHandshakeRequestsJob: Job? = null
+
private val handshakeTimeout = atomic<Job?>(null)
/**
@@ -119,6 +126,7 @@ class MeshConnectionManagerImpl(
* Connecting-state guard inside [onHandshakeProgress] is insufficient on its own.
*/
private val handshakeCompleteLatch = atomic(false)
+ private val locationRejectionLogged = atomic(false)
/**
* Consecutive handshake-recovery failure count for [runSiblingHandshakeRecovery].
@@ -165,14 +173,38 @@ class MeshConnectionManagerImpl(
nodeRepository.myNodeInfo
.onEach { myNodeEntity ->
locationRequestsJob?.cancel()
+ locationRejectionLogged.value = false
if (myNodeEntity != null) {
locationRequestsJob =
uiPrefs
.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
.onEach { shouldProvide ->
if (shouldProvide) {
- locationManager.start(scope) { pos -> commandSender.sendPosition(pos) }
+ locationManager.start(scope) { pos ->
+ val failure = safeCatching { commandSender.sendPosition(pos) }.exceptionOrNull()
+ when (failure) {
+ null -> locationRejectionLogged.value = false
+
+ is PacketQueueRejectedException ->
+ logLocationSendFailure(
+ failure,
+ "Location update was rejected by packet queue",
+ )
+
+ is LocalNodeUnavailableException ->
+ logLocationSendFailure(
+ failure,
+ "Location update is waiting for local node identity",
+ )
+
+ else ->
+ Logger.e(failure) {
+ "Location update failed unexpectedly; collector kept alive"
+ }
+ }
+ }
} else {
+ locationRejectionLogged.value = false
locationManager.stop()
}
}
@@ -425,6 +457,8 @@ class MeshConnectionManagerImpl(
}
private fun tearDownConnection() {
+ postHandshakeRequestsJob?.cancel()
+ postHandshakeRequestsJob = null
packetHandler.stopPacketQueue()
sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect.
locationManager.stop()
@@ -523,12 +557,7 @@ class MeshConnectionManagerImpl(
// orphan a job in the gap between cancel and reassign.
handshakeTimeout.getAndSet(null)?.cancel()
- val myNodeNum = nodeManager.myNodeNum.value ?: 0
- // Proactively seed the session passkey. The firmware embeds session_passkey in every
- // admin *response* (wantResponse=true), but set_time_only (sent at MyNodeInfo) has no
- // response. A get_owner request is the lightest way to trigger a response and populate the
- // passkey cache so that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
- commandSender.sendAdmin(myNodeNum, wantResponse = true) { AdminMessage(get_owner_request = true) }
+ schedulePostHandshakeRequests()
// Start MQTT if enabled
scope.handledLaunch {
@@ -540,18 +569,122 @@ class MeshConnectionManagerImpl(
}
reportConnection()
+ }
- // Request history
- scope.handledLaunch {
- val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
- moduleConfig.store_forward?.let {
- historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown")
+ private suspend fun schedulePostHandshakeRequests() = connectionMutex.withLock {
+ postHandshakeRequestsJob?.cancelAndJoin()
+ postHandshakeRequestsJob = null
+ val myNodeNum = nodeManager.myNodeNum.value
+ val connectedLifecycle = serviceRepository.connectionLifecycle.value
+ if (myNodeNum == null || connectedLifecycle.state !is ConnectionState.Connected) {
+ Logger.w { "Skipping post-handshake requests because the connected local-node state is unavailable" }
+ return@withLock
+ }
+ postHandshakeRequestsJob =
+ scope.handledLaunch {
+ // The requests are independent. One unexpected request failure must not cancel the others, and
+ // teardown serializes with this job publication through connectionMutex.
+ supervisorScope {
+ launch {
+ retryPostHandshakeRequest("Session-passkey seed", myNodeNum, connectedLifecycle.version) {
+ commandSender.sendAdminForConnection(
+ destNum = myNodeNum,
+ expectedConnectionVersion = connectedLifecycle.version,
+ wantResponse = true,
+ ) {
+ AdminMessage(get_owner_request = true)
+ }
+ }
+ }
+ listOf(TelemetryType.LOCAL_STATS, TelemetryType.DEVICE).forEach { type ->
+ launch {
+ retryPostHandshakeRequest(
+ label = "$type telemetry request",
+ myNodeNum = myNodeNum,
+ connectedVersion = connectedLifecycle.version,
+ ) {
+ commandSender.requestTelemetryForConnection(
+ commandSender.generatePacketId(),
+ myNodeNum,
+ type.ordinal,
+ connectedLifecycle.version,
+ )
+ }
+ }
+ }
+ launch {
+ val config = radioConfigRepository.moduleConfigFlow.first().store_forward ?: return@launch
+ retryPostHandshakeRequest(
+ label = "History replay",
+ myNodeNum = myNodeNum,
+ connectedVersion = connectedLifecycle.version,
+ ) {
+ historyManager.requestHistoryReplay(
+ trigger = "onNodeDbReady",
+ myNodeNum = myNodeNum,
+ storeForwardConfig = config,
+ transport = "Unknown",
+ expectedConnectionVersion = connectedLifecycle.version,
+ )
+ }
+ }
+ }
}
+ }
+
+ private fun logLocationSendFailure(failure: Throwable, warning: String) {
+ if (locationRejectionLogged.compareAndSet(expect = false, update = true)) {
+ Logger.w(failure) { warning }
+ } else {
+ Logger.d { "$warning (still pending)" }
+ }
+ }
+
+ private fun ownsPostHandshakeRequests(myNodeNum: Int, connectedVersion: Long): Boolean =
+ serviceRepository.connectionLifecycle.value.let { lifecycle ->
+ lifecycle.version == connectedVersion &&
+ lifecycle.state is ConnectionState.Connected &&
+ nodeManager.myNodeNum.value == myNodeNum
}
- // Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
+ private suspend fun retryPostHandshakeRequest(
+ label: String,
+ myNodeNum: Int,
+ connectedVersion: Long,
+ send: suspend () -> Unit,
+ ) {
+ var rejectionCount = 0
+ var rejectionLogged = false
+ var complete = false
+ while (!complete && ownsPostHandshakeRequests(myNodeNum, connectedVersion)) {
+ try {
+ send()
+ complete = true
+ } catch (e: PacketQueueRejectedException) {
+ rejectionCount++
+ val exhausted = rejectionCount >= MAX_POST_HANDSHAKE_ADMISSION_ATTEMPTS
+ if (exhausted) {
+ Logger.w(e) { "$label abandoned after $rejectionCount packet-queue rejections" }
+ } else {
+ if (!rejectionLogged) {
+ Logger.w(e) { "$label rejected; waiting for packet-queue admission" }
+ rejectionLogged = true
+ } else {
+ Logger.d { "$label still waiting for packet-queue admission" }
+ }
+ delay(postHandshakeAdmissionRetryDelay(rejectionCount))
+ }
+ complete = exhausted
+ } catch (e: LocalNodeUnavailableException) {
+ Logger.w(e) { "$label stopped because the local node became unavailable" }
+ complete = true
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "$label failed after the handshake" }
+ complete = true
+ }
+ }
}
/**
@@ -681,6 +814,16 @@ class MeshConnectionManagerImpl(
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
+ internal const val MAX_POST_HANDSHAKE_ADMISSION_ATTEMPTS = 8
+ private val POST_HANDSHAKE_ADMISSION_INITIAL_RETRY_DELAY = 1.seconds
+ private const val POST_HANDSHAKE_ADMISSION_MAX_BACKOFF_EXPONENT = 3
+
+ internal fun postHandshakeAdmissionRetryDelay(rejectionCount: Int): Duration {
+ require(rejectionCount > 0) { "rejectionCount must be positive" }
+ val exponent = (rejectionCount - 1).coerceAtMost(POST_HANDSHAKE_ADMISSION_MAX_BACKOFF_EXPONENT)
+ return POST_HANDSHAKE_ADMISSION_INITIAL_RETRY_DELAY * (1 shl exponent)
+ }
+
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
/**
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 125d7b4b1e..19696e8955 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -118,14 +118,6 @@ class MeshDataHandlerImpl(
private val scope: ServiceScope,
) : MeshDataHandler {
- private val rememberDataType =
- setOf(
- PortNum.TEXT_MESSAGE_APP.value,
- PortNum.ALERT_APP.value,
- PortNum.WAYPOINT_APP.value,
- PortNum.NODE_STATUS_APP.value,
- )
-
override fun handleReceivedData(
packet: MeshPacket,
myNodeNum: Int,
@@ -344,7 +336,7 @@ class MeshDataHandlerImpl(
session: RadioSessionContext,
) {
val decoded = packet.decoded ?: return
- if (decoded.reply_id != 0 && decoded.emoji != 0) {
+ if (decoded.isReaction()) {
rememberReaction(packet, dataPacket, session)
} else {
rememberDataPacket(dataPacket, myNodeNum, session = session)
@@ -440,7 +432,7 @@ class MeshDataHandlerImpl(
packetRepository.value.updateReaction(updated)
}
}
- packetHandler.removeResponse(requestId, complete = isAck)
+ packetHandler.completeDispatchedResponse(requestId, complete = isAck)
}
}
@@ -450,7 +442,7 @@ class MeshDataHandlerImpl(
updateNotification: Boolean,
session: RadioSessionContext?,
) {
- if (dataPacket.dataType !in rememberDataType) return
+ if (dataPacket.dataType !in PERSISTED_DATA_PORT_NUMBERS) return
radioInterfaceService.launchSessionWork(scope, session) {
persistDataPacket(dataPacket, myNodeNum, updateNotification)
}
@@ -459,8 +451,8 @@ class MeshDataHandlerImpl(
/**
* Deduplicates, filters, persists, and (when appropriate) notifies for a single [dataPacket]. Runs inside a session
* lease — callers must launch it via [RadioInterfaceService.launchSessionWork] and must have already confirmed the
- * packet's [DataPacket.dataType] is one of [rememberDataType]. Split out from [rememberDataPacket] so the waypoint
- * path can gate persistence on a repository read within the same lease (see [handleWaypoint]).
+ * packet's [DataPacket.dataType] is one of [PERSISTED_DATA_PORT_NUMBERS]. Split out from [rememberDataPacket] so
+ * the waypoint path can gate persistence on a repository read within the same lease (see [handleWaypoint]).
*/
private suspend fun persistDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
val fromLocal = dataPacket.isFromLocal(myNodeNum)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index 5b1648c7f3..55347d6664 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -17,33 +17,41 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.TimeoutCancellationException
-import kotlinx.coroutines.asDeferred
import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.di.ServiceScope
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.database.dao.shouldApplyOutgoingQueueStatus
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
+import org.meshtastic.core.repository.AwaitedSendResult
+import org.meshtastic.core.repository.AwaitedSendStatus
import org.meshtastic.core.repository.ConnectionStateProvider
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PersistedPacketId
+import org.meshtastic.core.repository.PersistedReactionId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
@@ -67,7 +75,13 @@ class PacketHandlerImpl(
) : PacketHandler {
companion object {
- private val TIMEOUT = 5.seconds
+ internal val RESPONSE_TIMEOUT = 5.seconds
+
+ /** Routing acknowledgements traverse the mesh and need a larger budget than the local queue response. */
+ internal val ROUTING_RESPONSE_TIMEOUT = 30.seconds
+ private val PERSISTED_STATUS_WAIT = 1.seconds
+ private val PERSISTED_STATUS_SHUTDOWN_WAIT = 150.milliseconds
+ internal val PERSISTED_STATUS_RETRY_DELAY = 100.milliseconds
/**
* Grace period after which a sent packet still [MessageStatus.ENROUTE] is stamped [Routing.Error.TIMEOUT]
@@ -93,28 +107,111 @@ class PacketHandlerImpl(
}
private var queueJob: Job? = null
+ private var queueGeneration = 0L
+ // Lock-order invariant: acquire queueMutex before responseMutex whenever both are needed. Never acquire
+ // responseMutex first on a path that can later need queueMutex, or admission and teardown can deadlock.
private val queueMutex = Mutex()
- private val queuedPackets = mutableListOf<MeshPacket>()
+ private val queuedPackets = mutableListOf<QueuedPacket>()
- // Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
- // and the queue processor's finally block to prevent restarting a stopped queue.
+ // Marks the current queue generation as drained. The next admission clears it so reconnect recovery can start a
+ // fresh worker; stale workers retain their generation and cannot replace that worker.
private var queueStopped = false
private val responseMutex = Mutex()
- private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
- private val routingResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
+
+ private data class QueuedPacket(
+ val packet: MeshPacket,
+ val pending: PendingResponse,
+ val expectedConnectionVersion: Long?,
+ )
+
+ private enum class StatusPersistence {
+ DATA_PACKET,
+ REACTION,
+ }
+
+ private data class PacketStatusTarget(val packet: MeshPacket, val persistence: StatusPersistence?)
+
+ private sealed interface PersistedStatusTarget {
+ data class DataPacket(val id: PersistedPacketId) : PersistedStatusTarget
+
+ data class Reaction(val id: PersistedReactionId) : PersistedStatusTarget
+ }
+
+ private class PendingResponse(val packet: MeshPacket, val persistence: StatusPersistence?, awaitRouting: Boolean) {
+ val queueDeferred = CompletableDeferred<AwaitedSendStatus>()
+ val routingDeferred = CompletableDeferred<AwaitedSendStatus>().takeIf { awaitRouting }
+ val dispatchAccepted = CompletableDeferred<Boolean>()
+ private val dispatchResolved = atomic(false)
+ private val dispatchDepartureEpoch = atomic<Long?>(null)
+ private val queueStatus = atomic<AwaitedSendStatus?>(null)
+ private val routingStatus = atomic<AwaitedSendStatus?>(null)
+
+ val wasDispatched: Boolean
+ get() = dispatchDepartureEpoch.value != null
+
+ val departureEpochAtDispatch: Long?
+ get() = dispatchDepartureEpoch.value
+
+ val terminalStatus: AwaitedSendStatus?
+ get() = routingStatus.value ?: queueStatus.value
+
+ val isTerminal: Boolean
+ get() = queueDeferred.isCompleted && (routingDeferred?.isCompleted != false)
+
+ fun recordDispatch(accepted: Boolean, departureEpoch: Long) {
+ if (!dispatchResolved.compareAndSet(expect = false, update = true)) return
+ if (accepted) dispatchDepartureEpoch.value = departureEpoch
+ dispatchAccepted.complete(accepted)
+ }
+
+ fun completeQueue(status: AwaitedSendStatus, routingTerminal: Boolean = status != AwaitedSendStatus.ACCEPTED) {
+ if (queueDeferred.complete(status)) queueStatus.compareAndSet(null, status)
+ if (routingTerminal) completeRouting(status)
+ }
+
+ fun completeRouting(status: AwaitedSendStatus) {
+ val deferred = routingDeferred ?: return
+ if (deferred.complete(status)) routingStatus.compareAndSet(null, status)
+ }
+
+ fun completeAll(status: AwaitedSendStatus) {
+ if (dispatchResolved.compareAndSet(expect = false, update = true)) dispatchAccepted.complete(false)
+ completeQueue(status, routingTerminal = true)
+ }
+
+ fun sendFailureStatus(): AwaitedSendStatus =
+ if (wasDispatched) AwaitedSendStatus.TRANSPORT_STOPPED else AwaitedSendStatus.SEND_FAILED
+ }
+
+ private sealed interface QueueAdmission {
+ data class Admitted(val pending: PendingResponse) : QueueAdmission
+
+ data object DuplicateId : QueueAdmission
+
+ data object ScopeInactive : QueueAdmission
+
+ data object TransportUnavailable : QueueAdmission
+ }
+
+ private val queueResponse = mutableMapOf<Int, PendingResponse>()
private val timeoutMutex = Mutex()
- private val sendAckTimeoutJobs = mutableMapOf<PersistedPacketId, Job>()
+ private val sendAckTimeoutJobs = mutableMapOf<PersistedStatusTarget, Job>()
override fun sendToRadio(p: ToRadio) {
+ if (!dispatchToRadio(p)) {
+ Logger.w { "sendToRadio dropped: no active transport accepted outbound command" }
+ }
+ }
+
+ private fun dispatchToRadio(p: ToRadio): Boolean {
Logger.d { "Sending to radio ${p.toPIIString()}" }
- val b = p.encode()
+ val dispatched = radioInterfaceService.trySendToRadio(p.encode())
+ if (!dispatched) return false
- radioInterfaceService.sendToRadio(b)
p.packet?.let { changeStatus(it, MessageStatus.ENROUTE) }
-
val packet = p.packet
if (packet?.decoded != null) {
val packetToSave =
@@ -129,6 +226,7 @@ class PacketHandlerImpl(
)
insertMeshLog(packetToSave)
}
+ return true
}
/**
@@ -137,57 +235,162 @@ class PacketHandlerImpl(
* multiple calls — e.g. an `editSettings { … }` begin → writes → commit sequence — MUST be issued from a single
* coroutine; concurrent senders share FIFO only at the per-call grain.
*/
- override suspend fun sendToRadio(packet: MeshPacket) {
- queueMutex.withLock {
- queueStopped = false
- queuedPackets.add(packet)
- startPacketQueueLocked()
+ override suspend fun sendToRadio(packet: MeshPacket): Boolean =
+ enqueueForSend(packet, expectedConnectionVersion = null)
+
+ override suspend fun sendToRadioForConnection(packet: MeshPacket, expectedConnectionVersion: Long): Boolean =
+ enqueueForSend(packet, expectedConnectionVersion)
+
+ private suspend fun enqueueForSend(packet: MeshPacket, expectedConnectionVersion: Long?): Boolean {
+ if (packet.id == 0) {
+ Logger.w { "Dropping queued packet without an ID" }
+ return false
+ }
+ return when (enqueuePacket(packet, expectedConnectionVersion = expectedConnectionVersion)) {
+ is QueueAdmission.Admitted -> true
+
+ QueueAdmission.TransportUnavailable -> {
+ Logger.w { "Rejecting packet id=${packet.id.toUInt()}: radio is not connected" }
+ false
+ }
+
+ QueueAdmission.ScopeInactive -> {
+ Logger.w { "Rejecting packet id=${packet.id.toUInt()}: service scope is no longer active" }
+ false
+ }
+
+ QueueAdmission.DuplicateId -> {
+ Logger.w { "Rejecting packet with reserved id=${packet.id.toUInt()}" }
+ false
+ }
}
}
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
- override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
- if (connectionStateProvider.connectionState.value != ConnectionState.Connected) {
- Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} skipped: not connected" }
- return false
+ override suspend fun sendToRadioAndAwaitResult(packet: MeshPacket): AwaitedSendResult = if (packet.id == 0) {
+ Logger.w { "Rejecting awaited packet without an ID" }
+ AwaitedSendResult(status = AwaitedSendStatus.REJECTED)
+ } else {
+ when (val admission = enqueuePacket(packet, awaitRouting = true)) {
+ is QueueAdmission.Admitted -> awaitAdmittedPacket(packet, admission.pending)
+
+ QueueAdmission.TransportUnavailable -> {
+ Logger.w { "Rejecting awaited packet id=${packet.id.toUInt()}: radio is not connected" }
+ AwaitedSendResult(status = AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ QueueAdmission.ScopeInactive -> {
+ Logger.w { "Rejecting awaited packet id=${packet.id.toUInt()}: service scope is no longer active" }
+ AwaitedSendResult(status = AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ QueueAdmission.DuplicateId -> {
+ Logger.w { "Rejecting duplicate awaited packet id=${packet.id.toUInt()}" }
+ AwaitedSendResult(status = AwaitedSendStatus.REJECTED)
+ }
}
+ }
- // QueueStatus(res=0) only means that firmware queued the packet. Keep a separate
- // routing response so this strict caller waits for the later Routing ACK/NAK.
- val deferred = CompletableDeferred<Boolean>()
- responseMutex.withLock { routingResponse[packet.id] = deferred }
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun awaitAdmittedPacket(packet: MeshPacket, pending: PendingResponse): AwaitedSendResult {
+ val routing = checkNotNull(pending.routingDeferred) { "awaited packet must reserve a routing response" }
return try {
- sendToRadio(packet)
- withTimeout(TIMEOUT) { deferred.await() }
- } catch (e: TimeoutCancellationException) {
- Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" }
- false
+ awaitPendingOrServiceStop(pending.dispatchAccepted)
+ val status = awaitPendingOrServiceStop(routing)
+ AwaitedSendResult(status = status, departureEpochAtDispatch = pending.departureEpochAtDispatch)
} catch (e: CancellationException) {
- throw e // Preserve structured concurrency cancellation propagation.
+ throw e // Caller cancellation does not release queue or response ownership.
} catch (e: Exception) {
- Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" }
- false
- } finally {
- responseMutex.withLock { routingResponse.remove(packet.id) }
+ Logger.w(e) { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed" }
+ val failureStatus = pending.sendFailureStatus()
+ responseMutex.withLock {
+ pending.completeRouting(failureStatus)
+ removePendingIfTerminalLocked(packet.id, pending)
+ }
+ AwaitedSendResult(
+ status = pending.terminalStatus ?: failureStatus,
+ departureEpochAtDispatch = pending.departureEpochAtDispatch,
+ )
+ }
+ }
+
+ /**
+ * Reserves [packet]'s non-zero ID and queues it as one atomic admission. The reservation spans queued work, the
+ * firmware queue result, and (for strict callers) the later routing acknowledgement.
+ */
+ private suspend fun enqueuePacket(
+ packet: MeshPacket,
+ awaitRouting: Boolean = false,
+ expectedConnectionVersion: Long? = null,
+ ): QueueAdmission = queueMutex.withLock {
+ responseMutex.withLock responseLock@{
+ if (!scope.isActive) return@responseLock QueueAdmission.ScopeInactive
+ if (expectedConnectionVersion == null) {
+ if (connectionStateProvider.connectionState.value != ConnectionState.Connected) {
+ return@responseLock QueueAdmission.TransportUnavailable
+ }
+ } else {
+ val lifecycle = connectionStateProvider.connectionLifecycle.value
+ if (
+ lifecycle.state !is ConnectionState.Connected || lifecycle.version != expectedConnectionVersion
+ ) {
+ return@responseLock QueueAdmission.TransportUnavailable
+ }
+ }
+ if (queueResponse.containsKey(packet.id)) return@responseLock QueueAdmission.DuplicateId
+
+ val pending =
+ PendingResponse(
+ packet = packet,
+ persistence = packet.statusPersistence(),
+ awaitRouting = awaitRouting,
+ )
+ queueResponse[packet.id] = pending
+ queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
+ queuedPackets.add(
+ QueuedPacket(
+ packet = packet,
+ pending = pending,
+ expectedConnectionVersion = expectedConnectionVersion,
+ ),
+ )
+ startPacketQueueLocked()
+ QueueAdmission.Admitted(pending)
+ }
+ }
+
+ /** Waits for one pending stage while converting permanent service shutdown into a synchronous queue drain. */
+ private suspend fun <T> awaitPendingOrServiceStop(deferred: Deferred<T>): T {
+ val scopeJob = scope.coroutineContext[Job] ?: return deferred.await()
+ val serviceStopped = select {
+ deferred.onAwait { false }
+ scopeJob.onJoin { true }
}
+ if (serviceStopped) {
+ withContext(NonCancellable) {
+ val failedPacketIds =
+ queueMutex.withLock { if (!deferred.isCompleted) stopAndDrainPacketQueueLocked() else emptyList() }
+ changeStatusesNow(failedPacketIds, MessageStatus.ERROR, PERSISTED_STATUS_SHUTDOWN_WAIT)
+ }
+ }
+ return deferred.await()
}
override fun stopPacketQueue() {
// Run async so callers (non-suspend) don't block, but all mutations are
// serialized under the same mutexes used by the queue processor and senders.
- scope.launch {
+ scope.handledLaunch {
Logger.i { "Stopping packet queueJob" }
- queueMutex.withLock {
- queueStopped = true
- queueJob?.cancel()
- queueJob = null
- queuedPackets.clear()
- }
- responseMutex.withLock {
- queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
- queueResponse.clear()
- routingResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
- routingResponse.clear()
+ withContext(NonCancellable) {
+ val failedPacketIds =
+ queueMutex.withLock {
+ queueStopped = true
+ queueJob?.cancel()
+ queueJob = null
+ queueGeneration++
+ queuedPackets.clear()
+ completePendingResponses(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+ changeStatusesNow(failedPacketIds, MessageStatus.ERROR, PERSISTED_STATUS_SHUTDOWN_WAIT)
}
}
}
@@ -196,36 +399,37 @@ class PacketHandlerImpl(
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) =
with(queueStatus) { Triple(res == 0 || res == ERRNO_SHOULD_RELEASE, free == 0, mesh_packet_id) }
- // Only the plain res==0 "queue accepted, now full" echo is skipped here. ERRNO_SHOULD_RELEASE denotes a
- // synchronous local-loopback delivery that still needs its queueResponse completed, even when free==0, or it
- // would hang until TIMEOUT.
- if (queueStatus.res == 0 && isFull) return
+ // Only plain res==0 "accepted, now full" is an advisory echo. ERRNO_SHOULD_RELEASE is synchronous local
+ // delivery and therefore completes both the firmware-queue stage and any strict routing waiter.
+ if (queueStatus.res == 0 && isFull && requestId == 0) return
- scope.launch {
+ scope.handledLaunch {
responseMutex.withLock {
- if (requestId != 0) {
- queueResponse.remove(requestId)?.complete(success)
- if (!success || queueStatus.res == ERRNO_SHOULD_RELEASE) {
- routingResponse.remove(requestId)?.complete(success)
+ val entry =
+ if (requestId != 0) {
+ queueResponse[requestId]?.let { requestId to it }
+ } else {
+ // Firmware can omit mesh_packet_id for the active queue entry. Strict routing waiters whose
+ // queue stage already completed remain in the map, so correlate only an unfinished queue stage.
+ queueResponse.entries
+ .firstOrNull { (_, pending) -> pending.wasDispatched && !pending.queueDeferred.isCompleted }
+ ?.let { it.key to it.value }
}
- } else {
- queueResponse.entries
- .firstOrNull { !it.value.isCompleted }
- ?.let { (packetId, response) ->
- response.complete(success)
- if (!success || queueStatus.res == ERRNO_SHOULD_RELEASE) {
- routingResponse.remove(packetId)?.complete(success)
- }
- }
- }
+ val (packetId, pending) = entry ?: return@withLock
+ if (!pending.wasDispatched) return@withLock
+
+ val status = success.toAwaitedSendStatus()
+ pending.completeQueue(status, routingTerminal = !success || queueStatus.res == ERRNO_SHOULD_RELEASE)
+ removePendingIfTerminalLocked(packetId, pending)
}
}
}
- override suspend fun removeResponse(dataRequestId: Int, complete: Boolean) {
+ override suspend fun completeDispatchedResponse(dataRequestId: Int, complete: Boolean) {
responseMutex.withLock {
- queueResponse.remove(dataRequestId)?.complete(complete)
- routingResponse.remove(dataRequestId)?.complete(complete)
+ val pending = queueResponse[dataRequestId]?.takeIf { it.wasDispatched } ?: return@withLock
+ pending.completeRouting(complete.toAwaitedSendStatus())
+ removePendingIfTerminalLocked(dataRequestId, pending)
}
}
@@ -234,66 +438,112 @@ class PacketHandlerImpl(
* atomic — preventing two concurrent callers from launching duplicate processors.
*/
private fun startPacketQueueLocked() {
- if (queueStopped) return
- if (queueJob?.isActive == true) return
+ check(queueMutex.isLocked) { "Packet queue workers must start while queueMutex is held" }
+ if (queueStopped || queueJob?.isActive == true) return
+
+ val generation = ++queueGeneration
+ // Install cleanup before admission releases queueMutex so cancellation cannot bypass the worker's finally.
+ // UNDISPATCHED enters immediately, then suspends on queueMutex until enqueuePacket releases the admission
+ // lock; this closes the cancellation gap without running queue mutation inside the caller's critical section.
queueJob =
- scope.handledLaunch {
+ scope.handledLaunch(start = CoroutineStart.UNDISPATCHED) {
try {
while (connectionStateProvider.connectionState.value == ConnectionState.Connected) {
- val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
- try {
- val response = sendPacket(packet)
- Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
- val success = withTimeout(TIMEOUT) { response.await() }
- Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
- } catch (e: TimeoutCancellationException) {
- Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
- // Clean up the transport-queue deferred for this packet.
- responseMutex.withLock { queueResponse.remove(packet.id) }
- } catch (e: CancellationException) {
- throw e // Preserve structured concurrency cancellation propagation.
- } catch (e: Exception) {
- Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
- responseMutex.withLock { queueResponse.remove(packet.id) }
- }
- // Deferred cleanup is now handled in the catch blocks above.
- // handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup)
- // also remove entries, and these removals are idempotent.
+ val queuedPacket = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
+ processQueuedPacket(queuedPacket)
}
} finally {
- // Hold queueMutex so that clearing queueJob and the restart decision are
- // atomic with respect to new senders calling startPacketQueueLocked().
- queueMutex.withLock {
- queueJob = null
- if (!queueStopped && queuedPackets.isNotEmpty()) {
- startPacketQueueLocked()
- }
- }
+ finishPacketQueueGeneration(generation)
}
}
}
- private fun changeStatus(packet: MeshPacket, status: MessageStatus) = scope.handledLaunch {
- if (packet.id != 0) {
- val persistedId =
- withTimeoutOrNull(1.seconds) {
- var id: PersistedPacketId? = null
- while (id == null) {
- id = packetRepository.value.updateOutgoingMessageStatus(packet, status)
- if (id == null) delay(100.milliseconds)
- }
- id
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun processQueuedPacket(queuedPacket: QueuedPacket) {
+ val (packet, pending, expectedConnectionVersion) = queuedPacket
+ try {
+ val response = sendPacket(packet, pending, expectedConnectionVersion)
+ Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting for QueueStatus" }
+ val status = withTimeout(RESPONSE_TIMEOUT) { response.await() }
+ Logger.d { "queueJob packet id=${packet.id.toUInt()} queue status $status" }
+ if (status != AwaitedSendStatus.ACCEPTED) changeStatus(packet, MessageStatus.ERROR)
+ removePendingIfTerminal(packet.id, pending)
+ } catch (_: TimeoutCancellationException) {
+ Logger.d { "queueJob packet id=${packet.id.toUInt()} queue response timeout" }
+ responseMutex.withLock {
+ // QueueStatus is a bounded, lossy phone-side signal in firmware. A timeout after transport dispatch is
+ // therefore inconclusive: release the local queue stage, but keep any strict routing waiter alive.
+ pending.completeQueue(AwaitedSendStatus.TIMED_OUT, routingTerminal = false)
+ removePendingIfTerminalLocked(packet.id, pending)
+ }
+ } catch (e: CancellationException) {
+ withContext(NonCancellable) {
+ responseMutex.withLock {
+ pending.completeAll(AwaitedSendStatus.TRANSPORT_STOPPED)
+ removePendingIfTerminalLocked(packet.id, pending)
}
- if (status == MessageStatus.ENROUTE && persistedId != null) scheduleSendAckTimeout(persistedId)
+ if (pending.terminalStatus != AwaitedSendStatus.ACCEPTED) {
+ changeStatusNow(packet, MessageStatus.ERROR, PERSISTED_STATUS_SHUTDOWN_WAIT)
+ }
+ }
+ throw e // Preserve structured concurrency cancellation propagation.
+ } catch (e: Exception) {
+ Logger.w(e) { "queueJob packet id=${packet.id.toUInt()} failed" }
+ responseMutex.withLock {
+ pending.completeAll(pending.sendFailureStatus())
+ removePendingIfTerminalLocked(packet.id, pending)
+ }
+ if (pending.terminalStatus != AwaitedSendStatus.ACCEPTED) changeStatus(packet, MessageStatus.ERROR)
}
}
+ private suspend fun finishPacketQueueGeneration(generation: Long) = withContext(NonCancellable) {
+ // Keep completion, replacement, and disconnect draining atomic with new admissions. queueGeneration
+ // advances only under queueMutex: stopPacketQueue() drains every pending response, while
+ // startPacketQueueLocked() advances it only after the previous queueJob is inactive. Therefore a stale
+ // worker has already lost ownership to a path that drained or replaced it and must not clear its successor.
+ val failedPacketIds =
+ queueMutex.withLock {
+ if (generation != queueGeneration) return@withLock emptyList()
+ queueJob = null
+ when {
+ queueStopped || !scope.isActive -> stopAndDrainPacketQueueLocked()
+
+ connectionStateProvider.connectionState.value != ConnectionState.Connected ->
+ stopAndDrainPacketQueueLocked()
+
+ queuedPackets.isNotEmpty() -> {
+ startPacketQueueLocked()
+ emptyList()
+ }
+
+ else -> emptyList() // Strict routing waiters may remain after their QueueStatus completed.
+ }
+ }
+ changeStatusesNow(failedPacketIds, MessageStatus.ERROR, PERSISTED_STATUS_SHUTDOWN_WAIT)
+ }
+
+ private suspend fun stopAndDrainPacketQueueLocked(): List<PacketStatusTarget> {
+ queueStopped = true
+ queuedPackets.clear()
+ return completePendingResponses(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
override fun rearmSendAckTimeouts() {
scope.handledLaunch {
packetRepository.value.getEnroutePackets().forEach { persisted ->
val remaining = persisted.packet.time + SEND_ACK_TIMEOUT.inWholeMilliseconds - nowMillis
- scheduleSendAckTimeout(persisted.id, remaining.milliseconds.coerceAtLeast(REARM_GRACE))
+ scheduleSendAckTimeout(
+ PersistedStatusTarget.DataPacket(persisted.id),
+ remaining.milliseconds.coerceAtLeast(REARM_GRACE),
+ )
+ }
+ packetRepository.value.getEnrouteReactions().forEach { persisted ->
+ val remaining = persisted.reaction.timestamp + SEND_ACK_TIMEOUT.inWholeMilliseconds - nowMillis
+ scheduleSendAckTimeout(
+ PersistedStatusTarget.Reaction(persisted.id),
+ remaining.milliseconds.coerceAtLeast(REARM_GRACE),
+ )
}
}
}
@@ -303,45 +553,190 @@ class PacketHandlerImpl(
* response arrived) would stay ENROUTE — "Sending…" — forever. Stamp it as a retryable timeout instead; a late ACK
* still upgrades it via handleAckNak.
*
- * One timer per persisted row: re-arming supersedes the pending one, so repeated reconnects cannot pile up timers
- * for same send. Timers deliberately survive a disconnect — the ack genuinely never arrived, and the resulting
- * state is retryable — so a user who never reconnects still sees the send resolve.
+ * Packet and reaction timers are keyed by stable persisted-row identity. Re-arming supersedes the pending timer for
+ * the same target, and timers deliberately survive disconnects.
*/
- private suspend fun scheduleSendAckTimeout(id: PersistedPacketId, delayFor: Duration = SEND_ACK_TIMEOUT) {
+ private suspend fun scheduleSendAckTimeout(target: PersistedStatusTarget, delayFor: Duration = SEND_ACK_TIMEOUT) {
timeoutMutex.withLock {
- sendAckTimeoutJobs.remove(id)?.cancel()
+ sendAckTimeoutJobs.remove(target)?.cancel()
sendAckTimeoutJobs.values.removeAll { it.isCompleted }
- sendAckTimeoutJobs[id] =
+ sendAckTimeoutJobs[target] =
scope.handledLaunch {
delay(delayFor)
// Conditional in the DAO transaction: an ACK/NAK landing while this timer waited must win.
- packetRepository.value.timeOutEnroutePacket(id, Routing.Error.TIMEOUT.value)
+ when (target) {
+ is PersistedStatusTarget.DataPacket ->
+ packetRepository.value.timeOutEnroutePacket(target.id, Routing.Error.TIMEOUT.value)
+
+ is PersistedStatusTarget.Reaction ->
+ packetRepository.value.timeOutEnrouteReaction(target.id, Routing.Error.TIMEOUT.value)
+ }
+ }
+ }
+ }
+
+ private fun changeStatus(packet: MeshPacket, status: MessageStatus) =
+ scope.handledLaunch { changeStatusNow(packet, status) }
+
+ private suspend fun changeStatusNow(
+ packet: MeshPacket,
+ status: MessageStatus,
+ persistenceWait: Duration = PERSISTED_STATUS_WAIT,
+ ) {
+ changeStatusesNow(
+ targets = listOf(PacketStatusTarget(packet, packet.statusPersistence())),
+ status = status,
+ persistenceWait = persistenceWait,
+ )
+ }
+
+ /**
+ * Applies [status] to durable rows, sharing one brief wait across app payloads whose inserts may still be in
+ * flight. Data packets resolve by the full outgoing identity from #6624 before their exact persisted row is
+ * mutated, so sender-scoped packet-ID collisions and terminal ACK/NAK states both fail closed.
+ */
+ private suspend fun changeStatusesNow(
+ targets: Collection<PacketStatusTarget>,
+ status: MessageStatus,
+ persistenceWait: Duration = PERSISTED_STATUS_WAIT,
+ ) {
+ val waiting =
+ targets
+ .filter { it.packet.id != 0 && it.persistence != null }
+ .distinctBy { it.packet.id }
+ .associateByTo(mutableMapOf()) { it.packet.id }
+
+ withTimeoutOrNull(persistenceWait) {
+ while (waiting.isNotEmpty()) {
+ waiting.values.toList().forEach { target ->
+ if (applyQueueStatus(target, status)) waiting.remove(target.packet.id)
+ }
+ if (waiting.isNotEmpty()) delay(PERSISTED_STATUS_RETRY_DELAY)
+ }
+ }
+ waiting.keys.forEach { packetId -> logMissingStatusRow(packetId, status) }
+ }
+
+ private suspend fun applyQueueStatus(target: PacketStatusTarget, status: MessageStatus): Boolean =
+ when (target.persistence) {
+ StatusPersistence.DATA_PACKET -> {
+ val persisted = packetRepository.value.applyOutgoingQueueStatus(target.packet, status) ?: return false
+ val current = persisted.packet.status
+ val shouldPersist =
+ status == MessageStatus.ENROUTE &&
+ (current == MessageStatus.ENROUTE || shouldApplyOutgoingQueueStatus(current, status))
+ if (shouldPersist) {
+ scheduleSendAckTimeout(PersistedStatusTarget.DataPacket(persisted.id))
}
+ true
+ }
+
+ StatusPersistence.REACTION -> {
+ val persisted =
+ packetRepository.value.applyOutgoingReactionQueueStatus(target.packet.id, status) ?: return false
+ val current = persisted.reaction.status
+ if (
+ status == MessageStatus.ENROUTE &&
+ (current == MessageStatus.ENROUTE || shouldApplyOutgoingQueueStatus(current, status))
+ ) {
+ scheduleSendAckTimeout(PersistedStatusTarget.Reaction(persisted.id))
+ }
+ true
+ }
+
+ null -> true
+ }
+
+ private fun logMissingStatusRow(packetId: Int, status: MessageStatus) {
+ Logger.d { "Skipping $status for mesh packet id=${packetId.toUInt()}: no unambiguous persisted row" }
+ }
+
+ private fun MeshPacket.statusPersistence(): StatusPersistence? {
+ val data = decoded ?: return null
+ return when {
+ data.isReaction() -> StatusPersistence.REACTION
+ data.portnum.value in PERSISTED_DATA_PORT_NUMBERS -> StatusPersistence.DATA_PACKET
+ else -> null
}
}
@Suppress("TooGenericExceptionCaught")
- private suspend fun sendPacket(packet: MeshPacket): Deferred<Boolean> {
- // Register the transport-queue response before sending so an immediate QueueStatus cannot be missed.
- val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
+ private suspend fun sendPacket(
+ packet: MeshPacket,
+ pending: PendingResponse,
+ expectedConnectionVersion: Long?,
+ ): Deferred<AwaitedSendStatus> {
try {
- if (connectionStateProvider.connectionState.value != ConnectionState.Connected) {
- throw RadioNotConnectedException()
+ // Publish the dispatch epoch under the response mutex so an immediate QueueStatus/Routing response cannot
+ // complete this packet before transport ownership is visible.
+ responseMutex.withLock {
+ val lifecycle = connectionStateProvider.connectionLifecycle.value
+ if (
+ lifecycle.state !is ConnectionState.Connected ||
+ (expectedConnectionVersion != null && lifecycle.version != expectedConnectionVersion)
+ ) {
+ throw RadioNotConnectedException()
+ }
+ val departureEpoch = lifecycle.epochs.departures
+ val accepted = dispatchToRadio(ToRadio(packet = packet))
+ pending.recordDispatch(accepted = accepted, departureEpoch = departureEpoch)
+ if (!accepted) pending.completeAll(AwaitedSendStatus.SEND_FAILED)
+ }
+ if (pending.wasDispatched && pending.routingDeferred != null) {
+ scheduleRoutingResponseExpiry(packet.id, pending)
+ } else if (!pending.wasDispatched) {
+ Logger.w { "sendToRadio dropped: no active transport accepted id=${packet.id.toUInt()}" }
}
- sendToRadio(ToRadio(packet = packet))
} catch (ex: RadioNotConnectedException) {
Logger.w(ex) { "sendToRadio skipped: Not connected to radio" }
- removeResponse(packet.id, complete = false)
+ responseMutex.withLock { pending.completeAll(AwaitedSendStatus.TRANSPORT_STOPPED) }
+ } catch (ex: CancellationException) {
+ throw ex
} catch (ex: Exception) {
Logger.e(ex) { "sendToRadio error: ${ex.message}" }
- removeResponse(packet.id, complete = false)
+ responseMutex.withLock { pending.completeAll(pending.sendFailureStatus()) }
+ }
+ return pending.queueDeferred
+ }
+
+ /**
+ * Owns strict-routing expiry from service scope so caller cancellation cannot strand a packet-ID reservation. Queue
+ * rejection, routing completion, disconnect, and service shutdown can all finish the pending response first; the
+ * identity check in [removePendingIfTerminalLocked] keeps a late expiry from touching a reused ID.
+ */
+ private fun scheduleRoutingResponseExpiry(packetId: Int, pending: PendingResponse) {
+ scope.handledLaunch {
+ delay(ROUTING_RESPONSE_TIMEOUT)
+ responseMutex.withLock {
+ pending.completeRouting(AwaitedSendStatus.TIMED_OUT)
+ removePendingIfTerminalLocked(packetId, pending)
+ }
}
- // Return a read-only Deferred view (kotlinx.coroutines 1.11+) so callers can await it
- // without being able to complete the underlying CompletableDeferred; cancellation is
- // still exposed via Deferred/Job.
- return deferred.asDeferred()
}
+ private suspend fun removePendingIfTerminal(packetId: Int, pending: PendingResponse) =
+ withContext(NonCancellable) { responseMutex.withLock { removePendingIfTerminalLocked(packetId, pending) } }
+
+ private fun removePendingIfTerminalLocked(packetId: Int, pending: PendingResponse) {
+ if (pending.isTerminal && queueResponse[packetId] === pending) queueResponse.remove(packetId)
+ }
+
+ private fun Boolean.toAwaitedSendStatus(): AwaitedSendStatus =
+ if (this) AwaitedSendStatus.ACCEPTED else AwaitedSendStatus.RADIO_REJECTED
+
+ private suspend fun completePendingResponses(status: AwaitedSendStatus): List<PacketStatusTarget> =
+ responseMutex.withLock {
+ val completedPackets =
+ queueResponse.mapNotNull { (_, pending) ->
+ pending.completeAll(status)
+ PacketStatusTarget(pending.packet, pending.persistence).takeIf {
+ pending.terminalStatus != AwaitedSendStatus.ACCEPTED
+ }
+ }
+ queueResponse.clear()
+ completedPackets
+ }
+
private fun insertMeshLog(packetToSave: MeshLog) {
scope.handledLaunch {
Logger.d {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index 2e5996ad8a..6c1030689d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -42,6 +42,8 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.repository.PersistedPacket
import org.meshtastic.core.repository.PersistedPacketId
+import org.meshtastic.core.repository.PersistedReaction
+import org.meshtastic.core.repository.PersistedReactionId
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -128,12 +130,36 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
.map { PersistedPacket(id = PersistedPacketId(it.myNodeNum, it.uuid), packet = it.data) }
}
+ override suspend fun getEnrouteReactions(): List<PersistedReaction> = withContext(dispatchers.io) {
+ dbManager.currentDb.value.packetDao().getReactionsByStatus(MessageStatus.ENROUTE).map { entity ->
+ PersistedReaction(
+ id = PersistedReactionId(entity.myNodeNum, entity.replyId, entity.userId, entity.emoji),
+ reaction = entity.toReaction { null },
+ )
+ }
+ }
+
// A null from withDb means no database was available, so nothing was timed out.
override suspend fun timeOutEnroutePacket(id: PersistedPacketId, routingError: Int): Boolean =
withContext(dispatchers.io + NonCancellable) {
dbManager.withDb { it.packetDao().timeOutEnroutePacket(id.myNodeNum, id.uuid, routingError) } ?: false
}
+ // A null from withDb means no database was available, so nothing was timed out.
+ override suspend fun timeOutEnrouteReaction(id: PersistedReactionId, routingError: Int): Boolean =
+ withContext(dispatchers.io + NonCancellable) {
+ dbManager.withDb {
+ it.packetDao()
+ .timeOutEnrouteReaction(
+ myNodeNum = id.myNodeNum,
+ replyId = id.replyId,
+ userId = id.userId,
+ emoji = id.emoji,
+ routingError = routingError,
+ )
+ } ?: false
+ }
+
suspend fun insertRoomPacket(packet: RoomPacket): Long = withContext(dispatchers.io + NonCancellable) {
checkNotNull(dbManager.withDb { it.packetDao().insertAndGetId(packet) })
}
@@ -283,6 +309,31 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
?.let { PersistedPacketId(it.myNodeNum, it.uuid) }
}
+ override suspend fun resolveOutgoingPacket(packet: MeshPacket): PersistedPacket? = withContext(dispatchers.io) {
+ dbManager.currentDb.value.packetDao().resolveOutgoingPacket(packet)?.let { stored ->
+ PersistedPacket(PersistedPacketId(stored.myNodeNum, stored.uuid), stored.data)
+ }
+ }
+
+ override suspend fun applyOutgoingQueueStatus(packet: MeshPacket, status: MessageStatus): PersistedPacket? =
+ withContext(dispatchers.io + NonCancellable) {
+ dbManager
+ .withDb { it.packetDao().applyOutgoingQueueStatus(packet, status) }
+ ?.let { stored -> PersistedPacket(PersistedPacketId(stored.myNodeNum, stored.uuid), stored.data) }
+ }
+
+ override suspend fun applyOutgoingReactionQueueStatus(packetId: Int, status: MessageStatus): PersistedReaction? =
+ withContext(dispatchers.io + NonCancellable) {
+ dbManager
+ .withDb { it.packetDao().applyOutgoingReactionQueueStatus(packetId, status) }
+ ?.let { entity ->
+ PersistedReaction(
+ id = PersistedReactionId(entity.myNodeNum, entity.replyId, entity.userId, entity.emoji),
+ reaction = entity.toReaction { null },
+ )
+ }
+ }
+
override suspend fun updateMessageId(d: DataPacket, id: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.packetDao().updateMessageId(d, id) } }
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt
index 25a7b663da..277a9054c6 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/CommandSenderImplTest.kt
@@ -21,9 +21,11 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
+import dev.mokkery.matcher.capture.capture
import dev.mokkery.matcher.matches
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -36,9 +38,14 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.AwaitedSendResult
+import org.meshtastic.core.repository.AwaitedSendStatus
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
@@ -54,6 +61,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
@@ -162,7 +170,7 @@ class CommandSenderImplTest {
fun sendData_setsIdWhenZero() = runTest {
val packet = DataPacket(to = "^all", bytes = "hi".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value)
packet.id = 0
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.sendData(packet)
assertNotEquals(0, packet.id)
@@ -170,11 +178,25 @@ class CommandSenderImplTest {
@Test
fun sendData_setsStatusQueued() = runTest {
- val packet = DataPacket(to = "^all", bytes = "hello".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value)
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ val packet =
+ DataPacket(to = "^all", bytes = "hello".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0)
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.sendData(packet)
assertEquals(MessageStatus.QUEUED, packet.status)
+ assertTrue(packet.time > 0, "an admitted packet should receive a send timestamp")
+ }
+
+ @Test
+ fun sendData_marksPacketErrorAndThrowsWhenQueueRejectsIt() = runTest {
+ val packet =
+ DataPacket(to = "^all", bytes = "hello".encodeUtf8(), dataType = PortNum.TEXT_MESSAGE_APP.value, time = 0)
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> { commandSender.sendData(packet) }
+
+ assertEquals(MessageStatus.ERROR, packet.status)
+ assertEquals(0L, packet.time, "a rejected packet must not receive a send timestamp")
}
@Test
@@ -199,11 +221,63 @@ class CommandSenderImplTest {
fun sendAdmin_injectsSessionPasskey() = runTest {
val passkey = "secret".encodeUtf8()
every { sessionManager.getPasskey(DEST_NODE) } returns passkey
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
- commandSender.sendAdmin(DEST_NODE) { org.meshtastic.proto.AdminMessage(get_owner_request = true) }
+ commandSender.sendAdmin(DEST_NODE) { AdminMessage(get_owner_request = true) }
- verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
+ val adminMessage = AdminMessage.ADAPTER.decode(requireNotNull(packets.single().decoded).payload)
+ assertEquals(passkey, adminMessage.session_passkey)
+ }
+
+ @Test
+ fun sendAdmin_generatesNonZeroIdWhenCallerSuppliesZero() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
+
+ commandSender.sendAdmin(DEST_NODE, requestId = 0) { AdminMessage(get_owner_request = true) }
+
+ assertNotEquals(0, packets.single().id)
+ }
+
+ @Test
+ fun sendAdminSurfacesQueueRejection() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> { commandSender.sendAdmin(DEST_NODE) { AdminMessage() } }
+ }
+
+ @Test
+ fun sendAdminForConnectionForwardsLifecycleOwnershipToAdmission() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadioForConnection(capture(packets), 17L) } returns true
+
+ commandSender.sendAdminForConnection(DEST_NODE, expectedConnectionVersion = 17L) { AdminMessage() }
+
+ assertEquals(1, packets.size)
+ verifySuspend { packetHandler.sendToRadioForConnection(any<MeshPacket>(), 17L) }
+ }
+
+ @Test
+ fun sendAdminAwaitResult_generatesNonZeroIdWhenCallerSuppliesZero() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadioAndAwaitResult(capture(packets)) } returns
+ AwaitedSendResult(AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 0)
+
+ commandSender.sendAdminAwaitResult(DEST_NODE, requestId = 0) { AdminMessage(get_owner_request = true) }
+
+ assertNotEquals(0, packets.single().id)
+ }
+
+ @Test
+ fun sendAdminAwaitResult_preservesNonAcceptedStatus() = runTest {
+ val expected = AwaitedSendResult(AwaitedSendStatus.REJECTED)
+ everySuspend { packetHandler.sendToRadioAndAwaitResult(any<MeshPacket>()) } returns expected
+
+ val result = commandSender.sendAdminAwaitResult(DEST_NODE) { AdminMessage(get_owner_request = true) }
+
+ assertEquals(expected, result)
+ assertFalse(result.accepted)
}
// --- sendAdminImmediate ---
@@ -238,50 +312,134 @@ class CommandSenderImplTest {
@Test
fun requestTraceroute_recordsStartTime() = runTest {
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.requestTraceroute(requestId = 42, destNum = DEST_NODE)
verify { tracerouteHandler.recordStartTime(42) }
}
+ @Test
+ fun requestTraceroute_doesNotStartTimerWhenQueueRejectsPacket() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> {
+ commandSender.requestTraceroute(requestId = 42, destNum = DEST_NODE)
+ }
+
+ verify(exactly(0)) { tracerouteHandler.recordStartTime(any()) }
+ }
+
+ @Test
+ fun requestTelemetry_surfacesQueueRejection() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> {
+ commandSender.requestTelemetry(requestId = 42, destNum = DEST_NODE, typeValue = 0)
+ }
+ }
+
+ @Test
+ fun requestTelemetryForConnectionForwardsLifecycleOwnershipToAdmission() = runTest {
+ everySuspend { packetHandler.sendToRadioForConnection(any<MeshPacket>(), 23L) } returns true
+
+ commandSender.requestTelemetryForConnection(
+ requestId = 42,
+ destNum = DEST_NODE,
+ typeValue = 0,
+ expectedConnectionVersion = 23L,
+ )
+
+ verifySuspend { packetHandler.sendToRadioForConnection(any<MeshPacket>(), 23L) }
+ }
+
+ @Test
+ fun requestTraceroute_generatesNonZeroIdWhenCallerSuppliesZero() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
+
+ commandSender.requestTraceroute(requestId = 0, destNum = DEST_NODE)
+
+ val generatedId = packets.single().id
+ assertNotEquals(0, generatedId)
+ verify { tracerouteHandler.recordStartTime(generatedId) }
+ }
+
// --- requestNeighborInfo ---
+ @Test
+ fun requestNeighborInfo_generatesNonZeroIdWhenCallerSuppliesZero() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
+
+ commandSender.requestNeighborInfo(requestId = 0, destNum = DEST_NODE)
+
+ val generatedId = packets.single().id
+ assertNotEquals(0, generatedId)
+ verify { neighborInfoHandler.recordStartTime(generatedId) }
+ }
+
@Test
fun requestNeighborInfo_localNode_usesCachedNeighborInfo() = runTest {
val cached = NeighborInfo(node_id = MY_NODE_NUM, last_sent_by_id = MY_NODE_NUM)
every { neighborInfoHandler.lastNeighborInfo } returns cached
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
+ verify { neighborInfoHandler.recordStartTime(1) }
}
@Test
fun requestNeighborInfo_localNode_generatesDummyWhenNoCached() = runTest {
every { neighborInfoHandler.lastNeighborInfo } returns null
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
+ verify { neighborInfoHandler.recordStartTime(1) }
}
@Test
fun requestNeighborInfo_remoteNode_sendsRequest() = runTest {
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
commandSender.requestNeighborInfo(requestId = 1, destNum = DEST_NODE)
verifySuspend { packetHandler.sendToRadio(any<MeshPacket>()) }
+ verify { neighborInfoHandler.recordStartTime(1) }
+ }
+
+ @Test
+ fun requestNeighborInfo_localNode_doesNotStartTimerWhenQueueRejectsPacket() = runTest {
+ every { neighborInfoHandler.lastNeighborInfo } returns null
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> {
+ commandSender.requestNeighborInfo(requestId = 1, destNum = MY_NODE_NUM)
+ }
+
+ verify(exactly(0)) { neighborInfoHandler.recordStartTime(any()) }
+ }
+
+ @Test
+ fun requestNeighborInfo_remoteNode_doesNotStartTimerWhenQueueRejectsPacket() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> {
+ commandSender.requestNeighborInfo(requestId = 1, destNum = DEST_NODE)
+ }
+
+ verify(exactly(0)) { neighborInfoHandler.recordStartTime(any()) }
}
// --- sendPosition ---
@Test
fun sendPosition_updatesLocalPositionWhenNotFixed() = runTest {
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
commandSender.sendPosition(pos)
@@ -289,6 +447,70 @@ class CommandSenderImplTest {
verify { nodeManager.handleReceivedPosition(MY_NODE_NUM, MY_NODE_NUM, any(), any()) }
}
+ @Test
+ fun sendPosition_doesNotUpdateLocalPositionWhenQueueRejectsPacket() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+ val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
+
+ assertFailsWith<PacketQueueRejectedException> { commandSender.sendPosition(pos) }
+
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun sendPosition_rejectsWhenLocalNodeIdentityIsUnavailable() = runTest {
+ every { nodeManager.myNodeNum } returns MutableStateFlow(null)
+
+ assertFailsWith<LocalNodeUnavailableException> { commandSender.sendPosition(org.meshtastic.proto.Position()) }
+
+ verifySuspend(exactly(0)) { packetHandler.sendToRadio(any<MeshPacket>()) }
+ }
+
+ @Test
+ fun requestUserInfo_rejectsWhenLocalNodeRecordIsUnavailable() = runTest {
+ assertFailsWith<LocalNodeUnavailableException> { commandSender.requestUserInfo(DEST_NODE) }
+
+ verifySuspend(exactly(0)) { packetHandler.sendToRadio(any<MeshPacket>()) }
+ }
+
+ @Test
+ fun setFixedPosition_rejectsBeforeDeviceMutationWhenLocalNodeIdentityIsUnavailable() = runTest {
+ every { nodeManager.myNodeNum } returns MutableStateFlow(null)
+
+ assertFailsWith<LocalNodeUnavailableException> {
+ commandSender.setFixedPosition(DEST_NODE, Position(latitude = 1.0, longitude = 2.0, altitude = 3))
+ }
+
+ verifySuspend(exactly(0)) { packetHandler.sendToRadio(any<MeshPacket>()) }
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun setFixedPosition_doesNotUpdateLocalPositionWhenQueueRejectsPacket() = runTest {
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
+
+ assertFailsWith<PacketQueueRejectedException> {
+ commandSender.setFixedPosition(DEST_NODE, Position(latitude = 1.0, longitude = 2.0, altitude = 3))
+ }
+
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun setFixedPosition_doesNotProjectZeroPositionWhenRemovingIt() = runTest {
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
+
+ commandSender.setFixedPosition(
+ DEST_NODE,
+ Position(latitude = 0.0, longitude = 0.0, altitude = 0, time = 1, satellitesInView = 9),
+ )
+
+ val adminMessage = AdminMessage.ADAPTER.decode(requireNotNull(packets.single().decoded).payload)
+ assertEquals(true, adminMessage.remove_fixed_position)
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any()) }
+ }
+
@Test
fun sendPosition_skipsLocalUpdateWhenFixedPosition() = runTest {
// Use MutableStateFlow so the init launchIn picks it up immediately in TestScope
@@ -308,7 +530,7 @@ class CommandSenderImplTest {
scope = testScope.asServiceScope(),
)
testScope.testScheduler.advanceUntilIdle()
- everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns Unit
+ everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
fixedSender.sendPosition(pos)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt
index 4223b47f44..a31bc0681e 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/HistoryManagerImplTest.kt
@@ -16,11 +16,35 @@
*/
package org.meshtastic.core.data.manager
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
+import dev.mokkery.matcher.capture.capture
+import dev.mokkery.mock
+import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode.Companion.exactly
+import dev.mokkery.verifySuspend
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import org.meshtastic.core.testing.FakeMeshPrefs
+import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
class HistoryManagerImplTest {
+ private val commandSender = mock<CommandSender>(MockMode.autofill)
+
+ private fun manager(meshPrefs: FakeMeshPrefs, packetHandler: PacketHandler) =
+ HistoryManagerImpl(meshPrefs, packetHandler, commandSender)
@Test
fun `buildStoreForwardHistoryRequest copies positive parameters`() {
@@ -67,4 +91,90 @@ class HistoryManagerImplTest {
assertEquals(1440, window)
assertEquals(100, max)
}
+
+ @Test
+ fun `requestHistoryReplay rejects a missing local node before queue admission`() = runTest {
+ val packetHandler = mock<PacketHandler>(MockMode.autofill)
+ val meshPrefs = FakeMeshPrefs().apply { setDeviceAddress("bAA:BB:CC:DD:EE:FF") }
+
+ assertFailsWith<LocalNodeUnavailableException> {
+ manager(meshPrefs, packetHandler)
+ .requestHistoryReplay(
+ trigger = "test",
+ myNodeNum = null,
+ storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
+ transport = "BLE",
+ expectedConnectionVersion = 17L,
+ )
+ }
+
+ verifySuspend(exactly(0)) { packetHandler.sendToRadioForConnection(any(), any()) }
+ }
+
+ @Test
+ fun `requestHistoryReplay rejects a missing device before queue admission`() = runTest {
+ val packetHandler = mock<PacketHandler>(MockMode.autofill)
+ val meshPrefs = FakeMeshPrefs()
+
+ assertFailsWith<LocalNodeUnavailableException> {
+ manager(meshPrefs, packetHandler)
+ .requestHistoryReplay(
+ trigger = "test",
+ myNodeNum = 123,
+ storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
+ transport = "BLE",
+ expectedConnectionVersion = 17L,
+ )
+ }
+
+ verifySuspend(exactly(0)) { packetHandler.sendToRadioForConnection(any(), any()) }
+ }
+
+ @Test
+ fun `requestHistoryReplay binds queue admission to the expected connection`() = runTest {
+ val packetHandler = mock<PacketHandler>(MockMode.autofill)
+ val meshPrefs = FakeMeshPrefs().apply { setDeviceAddress("bAA:BB:CC:DD:EE:FF") }
+ val packets = mutableListOf<MeshPacket>()
+ everySuspend { packetHandler.sendToRadioForConnection(capture(packets), 17L) } returns true
+ every { commandSender.generatePacketId() } returns 42
+
+ manager(meshPrefs, packetHandler)
+ .requestHistoryReplay(
+ trigger = "test",
+ myNodeNum = 123,
+ storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
+ transport = "BLE",
+ expectedConnectionVersion = 17L,
+ )
+
+ verifySuspend { packetHandler.sendToRadioForConnection(any(), 17L) }
+ verify { commandSender.generatePacketId() }
+ val queued = packets.single()
+ assertEquals(42, queued.id)
+ assertEquals(123, queued.from)
+ assertEquals(123, queued.to)
+ assertEquals(PortNum.STORE_FORWARD_APP, queued.decoded?.portnum)
+ assertEquals(MeshPacket.Priority.BACKGROUND, queued.priority)
+ val request = StoreAndForward.ADAPTER.decode(checkNotNull(queued.decoded).payload)
+ assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
+ }
+
+ @Test
+ fun `requestHistoryReplay surfaces owned queue rejection`() = runTest {
+ val packetHandler = mock<PacketHandler>(MockMode.autofill)
+ val meshPrefs = FakeMeshPrefs().apply { setDeviceAddress("bAA:BB:CC:DD:EE:FF") }
+ everySuspend { packetHandler.sendToRadioForConnection(any(), 23L) } returns false
+ every { commandSender.generatePacketId() } returns 43
+
+ assertFailsWith<PacketQueueRejectedException> {
+ manager(meshPrefs, packetHandler)
+ .requestHistoryReplay(
+ trigger = "test",
+ myNodeNum = 123,
+ storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
+ transport = "BLE",
+ expectedConnectionVersion = 23L,
+ )
+ }
+ }
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt
index 9c4744239a..52eb52c2c0 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/LockdownCoordinatorImplTest.kt
@@ -16,18 +16,13 @@
*/
package org.meshtastic.core.data.manager
-import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.LockdownState
-import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.LockdownPassphraseStore
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.StoredPassphrase
+import org.meshtastic.core.testing.FakeCommandSender
import org.meshtastic.core.testing.FakeRadioInterfaceService
import org.meshtastic.core.testing.FakeServiceRepository
-import org.meshtastic.proto.AdminMessage
-import org.meshtastic.proto.ChannelSet
-import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LockdownStatus
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
@@ -70,75 +65,6 @@ class LockdownCoordinatorImplTest {
}
}
- private class FakeCommandSender : CommandSender {
- var lastPassphrase: String? = null
- var lastBoots: Int = 0
- var lastHours: Int = 0
- var lastMaxSessionSeconds: Int = 0
- var lastDisable: Boolean = false
- var lockNowCalled = false
-
- override fun sendLockdownPassphrase(
- passphrase: String,
- boots: Int,
- hours: Int,
- maxSessionSeconds: Int,
- disable: Boolean,
- ) {
- lastPassphrase = passphrase
- lastBoots = boots
- lastHours = hours
- lastMaxSessionSeconds = maxSessionSeconds
- lastDisable = disable
- }
-
- override fun sendLockNow() {
- lockNowCalled = true
- }
-
- // Unused stubs
- override fun getCurrentPacketId(): Long = 0L
-
- override fun getCachedLocalConfig(): LocalConfig = LocalConfig()
-
- override fun getCachedChannelSet(): ChannelSet = ChannelSet()
-
- override fun generatePacketId(): Int = 0
-
- override suspend fun sendData(p: DataPacket) = Unit
-
- override suspend fun sendAdmin(
- destNum: Int,
- requestId: Int,
- wantResponse: Boolean,
- initFn: () -> AdminMessage,
- ) = Unit
-
- override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) = Unit
-
- override suspend fun sendAdminAwait(
- destNum: Int,
- requestId: Int,
- wantResponse: Boolean,
- initFn: () -> AdminMessage,
- ) = true
-
- override suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) =
- Unit
-
- override suspend fun requestPosition(destNum: Int, currentPosition: Position) = Unit
-
- override suspend fun setFixedPosition(destNum: Int, pos: Position) = Unit
-
- override suspend fun requestUserInfo(destNum: Int) = Unit
-
- override suspend fun requestTraceroute(requestId: Int, destNum: Int) = Unit
-
- override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) = Unit
-
- override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) = Unit
- }
-
private class FakeConnectionManager : MeshConnectionManager {
var configOnlyCalled = false
var clearRadioConfigCalled = false
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index 3c44aa43bc..4ad96f9352 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -28,6 +28,7 @@ import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -41,8 +42,10 @@ import org.meshtastic.core.common.di.asServiceScope
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.ConnectionStateHolder
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshNotificationManager
@@ -51,6 +54,7 @@ import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PersistedPacket
import org.meshtastic.core.repository.PersistedPacketId
@@ -97,7 +101,7 @@ class MeshConnectionManagerImplTest {
private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
- private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
+ private lateinit var connectionStateHolder: ConnectionStateHolder
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
@@ -128,20 +132,22 @@ class MeshConnectionManagerImplTest {
testDispatcher = UnconfinedTestDispatcher()
radioConnectionState.value = ConnectionState.Disconnected
- connectionStateFlow.value = ConnectionState.Disconnected
+ connectionStateHolder = ConnectionStateHolder()
localConfigFlow.value = LocalConfig()
moduleConfigFlow.value = LocalModuleConfig()
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
- every { serviceRepository.connectionState } returns connectionStateFlow
+ every { serviceRepository.connectionState } returns connectionStateHolder.connectionState
+ every { serviceRepository.connectionLifecycle } returns connectionStateHolder.connectionLifecycle
every { serviceRepository.setConnectionState(any()) } calls
{ call ->
- connectionStateFlow.value = call.arg<ConnectionState>(0)
+ applyConnectionState(call.arg<ConnectionState>(0))
}
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
+ everySuspend { commandSender.sendAdminForConnection(any(), any(), any(), any(), any()) } returns Unit
+ everySuspend { commandSender.requestTelemetryForConnection(any(), any(), any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
@@ -173,6 +179,10 @@ class MeshConnectionManagerImplTest {
NodeRestartTracker(scope),
)
+ private fun applyConnectionState(state: ConnectionState) {
+ connectionStateHolder.setConnectionState(state)
+ }
+
private fun restartTransportCallCounter(): () -> Int {
var restartCalls = 0
everySuspend { radioInterfaceService.restartTransport() } calls { restartCalls += 1 }
@@ -337,15 +347,149 @@ class MeshConnectionManagerImplTest {
everySuspend { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.startProxy(any(), any()) } returns Unit
- everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
+ everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager = createManager(backgroundScope)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
manager.onNodeDbReady()
advanceUntilIdle()
verify { mqttManager.startProxy(true, true) }
- verifySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
+ verifySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `onNodeDbReady skips history when its lifecycle departs while config loads`() = runTest(testDispatcher) {
+ val delayedModuleConfig = MutableSharedFlow<LocalModuleConfig>()
+ every { radioConfigRepository.moduleConfigFlow } returns delayedModuleConfig
+ every { nodeManager.myNodeNum } returns MutableStateFlow(123)
+ every { mqttManager.startProxy(any(), any()) } returns Unit
+ everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) } returns Unit
+
+ manager = createManager(backgroundScope)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ manager.onNodeDbReady()
+ runCurrent()
+ assertTrue(
+ delayedModuleConfig.subscriptionCount.value >= 2,
+ "MQTT and history collectors must be active; additional collectors are allowed",
+ )
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ delayedModuleConfig.emit(LocalModuleConfig(store_forward = ModuleConfig.StoreForwardConfig(enabled = true)))
+ runCurrent()
+
+ verifySuspend(exactly(0)) { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `onNodeDbReady retains owned requests until queue admission opens`() = runTest(testDispatcher) {
+ var seedAttempts = 0
+ var historyAttempts = 0
+ val telemetryAttempts = mutableMapOf<Int, Int>()
+ val admissionVersions = mutableListOf<Long>()
+ moduleConfigFlow.value = LocalModuleConfig(store_forward = ModuleConfig.StoreForwardConfig(enabled = true))
+ everySuspend { commandSender.sendAdminForConnection(any(), any(), any(), any(), any()) } calls
+ { call ->
+ admissionVersions += call.arg<Long>(1)
+ seedAttempts++
+ if (seedAttempts < 3) throw PacketQueueRejectedException("test passkey seed")
+ Unit
+ }
+ everySuspend { commandSender.requestTelemetryForConnection(any(), any(), any(), any()) } calls
+ { call ->
+ admissionVersions += call.arg<Long>(3)
+ val type = call.arg<Int>(2)
+ val attempts = telemetryAttempts.getOrElse(type) { 0 } + 1
+ telemetryAttempts[type] = attempts
+ if (attempts == 1) throw PacketQueueRejectedException("test telemetry request")
+ Unit
+ }
+ everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) } calls
+ { call ->
+ admissionVersions += call.arg<Long>(4)
+ historyAttempts++
+ if (historyAttempts == 1) throw PacketQueueRejectedException("test history replay")
+ Unit
+ }
+ every { nodeManager.myNodeNum } returns MutableStateFlow(123)
+ every { mqttManager.startProxy(any(), any()) } returns Unit
+ every { nodeManager.getMyNodeInfo() } returns null
+
+ manager = createManager(backgroundScope)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ manager.onNodeDbReady()
+ runCurrent()
+ advanceTimeBy(
+ (
+ MeshConnectionManagerImpl.postHandshakeAdmissionRetryDelay(1) +
+ MeshConnectionManagerImpl.postHandshakeAdmissionRetryDelay(2)
+ )
+ .inWholeMilliseconds,
+ )
+ runCurrent()
+
+ assertEquals(3, seedAttempts)
+ assertEquals(2, historyAttempts)
+ assertEquals(2, telemetryAttempts[TelemetryType.LOCAL_STATS.ordinal])
+ assertEquals(2, telemetryAttempts[TelemetryType.DEVICE.ordinal])
+ assertEquals(setOf(connectionStateHolder.connectionLifecycle.value.version), admissionVersions.toSet())
+ }
+
+ @Test
+ fun `post-handshake admission retries stop when the connected lifecycle changes`() = runTest(testDispatcher) {
+ var seedAttempts = 0
+ everySuspend { commandSender.sendAdminForConnection(any(), any(), any(), any(), any()) } calls
+ {
+ seedAttempts++
+ throw PacketQueueRejectedException("queue closed")
+ }
+ every { nodeManager.myNodeNum } returns MutableStateFlow(123)
+ every { mqttManager.startProxy(any(), any()) } returns Unit
+ every { nodeManager.getMyNodeInfo() } returns null
+
+ manager = createManager(backgroundScope)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ manager.onNodeDbReady()
+ runCurrent()
+ assertEquals(1, seedAttempts)
+
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ advanceTimeBy(
+ (1 until MeshConnectionManagerImpl.MAX_POST_HANDSHAKE_ADMISSION_ATTEMPTS).sumOf { rejectionCount ->
+ MeshConnectionManagerImpl.postHandshakeAdmissionRetryDelay(rejectionCount).inWholeMilliseconds
+ },
+ )
+ runCurrent()
+
+ assertEquals(1, seedAttempts, "a stale connection generation must not keep retrying admission")
+ }
+
+ @Test
+ fun `post-handshake admission retries stop at the attempt cap`() = runTest(testDispatcher) {
+ var seedAttempts = 0
+ everySuspend { commandSender.sendAdminForConnection(any(), any(), any(), any(), any()) } calls
+ {
+ seedAttempts++
+ throw PacketQueueRejectedException("queue closed")
+ }
+ every { nodeManager.myNodeNum } returns MutableStateFlow(123)
+ every { mqttManager.startProxy(any(), any()) } returns Unit
+ every { nodeManager.getMyNodeInfo() } returns null
+
+ manager = createManager(backgroundScope)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ manager.onNodeDbReady()
+ runCurrent()
+
+ val retryWindowMillis =
+ (1 until MeshConnectionManagerImpl.MAX_POST_HANDSHAKE_ADMISSION_ATTEMPTS).sumOf { rejectionCount ->
+ MeshConnectionManagerImpl.postHandshakeAdmissionRetryDelay(rejectionCount).inWholeMilliseconds
+ }
+ advanceTimeBy(retryWindowMillis)
+ runCurrent()
+
+ assertEquals(MeshConnectionManagerImpl.MAX_POST_HANDSHAKE_ADMISSION_ATTEMPTS, seedAttempts)
}
@Test
@@ -398,7 +542,7 @@ class MeshConnectionManagerImplTest {
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
- connectionStateFlow.value = state
+ applyConnectionState(state)
}
manager = createManager(backgroundScope)
@@ -443,7 +587,7 @@ class MeshConnectionManagerImplTest {
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
- connectionStateFlow.value = state
+ applyConnectionState(state)
}
manager = createManager(backgroundScope)
@@ -627,7 +771,7 @@ class MeshConnectionManagerImplTest {
everySuspend { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
every { mqttManager.startProxy(any(), any()) } returns Unit
- everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
+ everySuspend { historyManager.requestHistoryReplay(any(), any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager = createManager(backgroundScope)
@@ -816,7 +960,7 @@ class MeshConnectionManagerImplTest {
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
- connectionStateFlow.value = state
+ applyConnectionState(state)
}
every { serviceRepository.setConnectionProgress(any()) } calls
{ call ->
@@ -834,13 +978,13 @@ class MeshConnectionManagerImplTest {
// split-brain this recovery path exists to break.
assertEquals(
ConnectionState.Disconnected,
- connectionStateFlow.value,
+ connectionStateHolder.connectionState.value,
"restartTransport must run AFTER app-level Disconnected transition",
)
assertEquals(
ServiceRepository.RECONNECTING_PROGRESS_TEXT,
progressBeforeRestart,
- "setConnectionProgress(ServiceRepository.RECONNECTING_PROGRESS_TEXT) must run before restartTransport",
+ "Reconnect progress must be published before restartTransport",
)
}
@@ -869,7 +1013,7 @@ class MeshConnectionManagerImplTest {
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
- connectionStateFlow.value = state
+ applyConnectionState(state)
}
every { serviceRepository.setConnectionProgress(any()) } calls
{ call ->
@@ -880,7 +1024,7 @@ class MeshConnectionManagerImplTest {
restartTransportCalls++
assertEquals(
ConnectionState.Disconnected,
- connectionStateFlow.value,
+ connectionStateHolder.connectionState.value,
"post-handshake recovery must disconnect app state before restarting transport",
)
assertEquals(
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 947e000fda..6cbe16c65e 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -509,58 +509,40 @@ class MeshDataHandlerTest {
// --- Routing/ACK-NAK handling ---
- @Test
- fun `routing packet with successful ack broadcasts and removes response`() = testScope.runTest {
- val routing = Routing(error_reason = Routing.Error.NONE)
+ private fun routingPacket(error: Routing.Error): MeshPacket {
+ val routing = Routing(error_reason = error)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
- val dataPacket =
+ every { dataMapper.toDataPacket(packet) } returns
DataPacket(
from = "!remote",
to = NodeAddress.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
- every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
+ return packet
+ }
- handler.handleReceivedData(packet, 123)
+ @Test
+ fun `routing ack completes dispatched response as accepted`() = testScope.runTest {
+ handler.handleReceivedData(routingPacket(Routing.Error.NONE), 123)
advanceUntilIdle()
- verifySuspend { packetHandler.removeResponse(99, complete = true) }
+ verifySuspend { packetHandler.completeDispatchedResponse(99, complete = true) }
}
@Test
- fun `routing packet with nak fails pending response`() = testScope.runTest {
- val routing = Routing(error_reason = Routing.Error.NO_ROUTE)
- val packet =
- MeshPacket(
- from = 456,
- decoded =
- Data(
- portnum = PortNum.ROUTING_APP,
- payload = routing.encode().toByteString(),
- request_id = 100,
- ),
- )
- val dataPacket =
- DataPacket(
- from = "!remote",
- to = NodeAddress.ID_BROADCAST,
- bytes = routing.encode().toByteString(),
- dataType = PortNum.ROUTING_APP.value,
- )
- every { dataMapper.toDataPacket(packet) } returns dataPacket
- every { nodeManager.toNodeID(456) } returns "!remote"
-
- handler.handleReceivedData(packet, 123)
+ fun `routing nak completes dispatched response as rejected`() = testScope.runTest {
+ // NO_ROUTE keeps this ownership test independent of errors that also raise localized UI warnings.
+ handler.handleReceivedData(routingPacket(Routing.Error.NO_ROUTE), 123)
advanceUntilIdle()
- verifySuspend { packetHandler.removeResponse(100, complete = false) }
+ verifySuspend { packetHandler.completeDispatchedResponse(99, complete = false) }
}
@Test
@@ -589,7 +571,7 @@ class MeshDataHandlerTest {
verifySuspend(exactly(0)) { packetRepository.findPacketsWithId(any()) }
verifySuspend(exactly(0)) { packetRepository.findReactionsWithId(any()) }
verifySuspend(exactly(0)) { packetRepository.update(any(), any()) }
- verifySuspend(exactly(0)) { packetHandler.removeResponse(any(), any()) }
+ verifySuspend(exactly(0)) { packetHandler.completeDispatchedResponse(any(), any()) }
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
index 5ce3c43e77..c8b74a396d 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.answering.throws
import dev.mokkery.every
@@ -29,20 +30,33 @@ import dev.mokkery.verifySuspend
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.di.asServiceScope
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionLifecycle
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.repository.AwaitedSendStatus
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PersistedPacket
import org.meshtastic.core.repository.PersistedPacketId
+import org.meshtastic.core.repository.PersistedReaction
+import org.meshtastic.core.repository.PersistedReactionId
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
@@ -51,6 +65,8 @@ import org.meshtastic.proto.PortNum
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.Routing
import org.meshtastic.proto.ToRadio
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -63,6 +79,8 @@ class PacketHandlerImplTest {
companion object {
private val PERSISTED_ID = PersistedPacketId(myNodeNum = 123, uuid = 456L)
+ private val PERSISTED_REACTION_ID =
+ PersistedReactionId(myNodeNum = 123, replyId = 1, userId = "!00000001", emoji = "👍")
}
private val packetRepository: PacketRepository = mock(MockMode.autofill)
@@ -70,26 +88,43 @@ class PacketHandlerImplTest {
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
- private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
-
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
+ private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
+ private val initialEpochs = ConnectionEpochs(departures = 7, lastDepartureState = ConnectionState.Disconnected)
+ private val connectionLifecycleFlow =
+ connectionStateFlow
+ .map { state -> ConnectionLifecycle(state = state, epochs = initialEpochs) }
+ .stateIn(
+ scope = testScope,
+ started = SharingStarted.Eagerly,
+ initialValue = ConnectionLifecycle(epochs = initialEpochs),
+ )
private lateinit var handler: PacketHandlerImpl
+ private val responseTimeoutCrossingMillis = PacketHandlerImpl.RESPONSE_TIMEOUT.inWholeMilliseconds + 1
+
+ private fun handlerWithScope(scope: TestScope) = PacketHandlerImpl(
+ lazy { packetRepository },
+ radioInterfaceService,
+ lazy { meshLogRepository },
+ serviceRepository,
+ scope.asServiceScope(),
+ )
+
@BeforeTest
fun setUp() {
every { serviceRepository.connectionState } returns connectionStateFlow
- everySuspend { packetRepository.updateOutgoingMessageStatus(any(), any()) } returns PERSISTED_ID
-
- handler =
- PacketHandlerImpl(
- lazy { packetRepository },
- radioInterfaceService,
- lazy { meshLogRepository },
- serviceRepository,
- testScope.asServiceScope(),
- )
+ every { serviceRepository.connectionLifecycle } returns connectionLifecycleFlow
+ every { radioInterfaceService.trySendToRadio(any()) } returns true
+
+ handler = handlerWithScope(testScope)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ testScope.cancel()
}
@Test
@@ -103,7 +138,7 @@ class PacketHandlerImplTest {
handler.sendToRadio(toRadio)
- verify { radioInterfaceService.sendToRadio(any()) }
+ verify { radioInterfaceService.trySendToRadio(any()) }
}
@Test
@@ -116,10 +151,13 @@ class PacketHandlerImplTest {
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(packet, MessageStatus.ENROUTE) } returns
+ PersistedPacket(PERSISTED_ID, storedTextPacket(id = 123, status = MessageStatus.QUEUED))
+
handler.sendToRadio(ToRadio(packet = packet))
testScheduler.runCurrent()
- verifySuspend { packetRepository.updateOutgoingMessageStatus(packet, MessageStatus.ENROUTE) }
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(packet, MessageStatus.ENROUTE) }
}
@Test
@@ -130,7 +168,84 @@ class PacketHandlerImplTest {
handler.sendToRadio(packet)
testScheduler.runCurrent()
- verify { radioInterfaceService.sendToRadio(any()) }
+ verify { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `reconnect between ownership capture and admission rejects the stale packet`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val lifecycleFlow = MutableStateFlow(ConnectionLifecycle(version = 11, state = ConnectionState.Connected))
+ every { serviceRepository.connectionLifecycle } returns lifecycleFlow
+ val ownedHandler = handlerWithScope(testScope)
+ val capturedVersion = lifecycleFlow.value.version
+
+ lifecycleFlow.value = ConnectionLifecycle(version = 13, state = ConnectionState.Connected)
+
+ val accepted = ownedHandler.sendToRadioForConnection(MeshPacket(id = 460), capturedVersion)
+
+ assertFalse(accepted)
+ verify(exactly(0)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `reconnect while an owned packet waits in the queue prevents stale dispatch`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val lifecycleFlow = MutableStateFlow(ConnectionLifecycle(version = 11, state = ConnectionState.Connected))
+ every { serviceRepository.connectionLifecycle } returns lifecycleFlow
+ val ownedHandler = handlerWithScope(testScope)
+ val capturedVersion = lifecycleFlow.value.version
+
+ assertTrue(ownedHandler.sendToRadio(MeshPacket(id = 460)))
+ testScheduler.runCurrent()
+ assertTrue(ownedHandler.sendToRadioForConnection(MeshPacket(id = 461), capturedVersion))
+ lifecycleFlow.value = ConnectionLifecycle(version = 13, state = ConnectionState.Connected)
+
+ ownedHandler.handleQueueStatus(QueueStatus(mesh_packet_id = 460, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ verify(exactly(1)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `awaited send rejects when service scope is already stopped`() = runTest(testDispatcher) {
+ val stoppedScope = TestScope(StandardTestDispatcher(testScheduler))
+ stoppedScope.cancel()
+ val stoppedHandler = handlerWithScope(stoppedScope)
+
+ val result = stoppedHandler.sendToRadioAndAwaitResult(MeshPacket(id = 457))
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, result.status)
+ assertFalse(result.dispatched)
+ }
+
+ @Test
+ fun `plain send rejects when service scope is already stopped`() = runTest(testDispatcher) {
+ val stoppedScope = TestScope(StandardTestDispatcher(testScheduler))
+ stoppedScope.cancel()
+ val stoppedHandler = handlerWithScope(stoppedScope)
+
+ val accepted = stoppedHandler.sendToRadio(MeshPacket(id = 459))
+
+ assertFalse(accepted)
+ verify(exactly(0)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `awaited send is released when service scope stops before worker starts`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val serviceScope = TestScope(StandardTestDispatcher(testScheduler))
+ val stoppedHandler = handlerWithScope(serviceScope)
+
+ val result =
+ async(start = CoroutineStart.UNDISPATCHED) {
+ stoppedHandler.sendToRadioAndAwaitResult(MeshPacket(id = 458))
+ }
+ serviceScope.cancel()
+ testScheduler.runCurrent()
+ val completed = result.await()
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, completed.status)
+ assertFalse(completed.dispatched)
}
@Test
@@ -183,17 +298,647 @@ class PacketHandlerImplTest {
assertTrue(result.await())
}
+ @Test
+ fun `packet-specific accepted full queue status completes the matching queue stage`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 793)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 793, res = 0, free = 0))
+ testScheduler.runCurrent()
+ assertFalse(result.isCompleted, "queue admission must not satisfy the strict routing waiter")
+
+ handler.completeDispatchedResponse(dataRequestId = 793, complete = true)
+ assertEquals(AwaitedSendStatus.ACCEPTED, result.await().status)
+ }
+
@Test
fun `strict await treats queue rejection as failure`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
- val result = async { handler.sendToRadioAndAwait(MeshPacket(id = 791)) }
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 791)) }
testScheduler.runCurrent()
handler.handleQueueStatus(QueueStatus(mesh_packet_id = 791, res = 33, free = 16))
testScheduler.runCurrent()
- assertFalse(result.await())
+ val rejected = result.await()
+ assertEquals(AwaitedSendStatus.RADIO_REJECTED, rejected.status)
+ assertTrue(rejected.dispatched)
+ assertEquals(initialEpochs.departures, rejected.departureEpochAtDispatch)
+ }
+
+ @Test
+ fun `await response timeout starts after earlier queued packets`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ handler.sendToRadio(MeshPacket(id = 800))
+ handler.sendToRadio(MeshPacket(id = 801))
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 802)) }
+ testScheduler.runCurrent()
+
+ // Let both earlier packets consume their full response windows. The awaited packet has not timed out
+ // because
+ // it has not reached the head of the queue yet.
+ testScheduler.advanceTimeBy(responseTimeoutCrossingMillis)
+ testScheduler.runCurrent()
+ assertFalse(result.isCompleted)
+ testScheduler.advanceTimeBy(responseTimeoutCrossingMillis)
+ testScheduler.runCurrent()
+ assertFalse(result.isCompleted)
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 802, res = 0, free = 16))
+ testScheduler.runCurrent()
+ assertFalse(result.isCompleted, "QueueStatus must not satisfy the strict routing waiter")
+ handler.completeDispatchedResponse(dataRequestId = 802, complete = true)
+
+ assertEquals(AwaitedSendStatus.ACCEPTED, result.await().status)
+ }
+
+ @Test
+ fun `stopping the queue completes an awaiting packet still behind the backlog`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ // Packet 803 owns the queue head, so 804 is stopped before the transport receives it.
+ val storedPacket = storedTextPacket(id = 804, status = MessageStatus.QUEUED)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+ handler.sendToRadio(MeshPacket(id = 803))
+ val result = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 804, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ testScheduler.runCurrent()
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ val stopped = result.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertFalse(stopped.dispatched)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `queue stop skips persistence lookup for a non-persisted packet`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ handler.sendToRadio(MeshPacket(id = 820))
+ val queued = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 821)) }
+ testScheduler.runCurrent()
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, queued.await().status)
+ verifySuspend(exactly(0)) { packetRepository.applyOutgoingQueueStatus(any(), any()) }
+ }
+
+ @Test
+ fun `text emoji without reply id remains a persisted app packet`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedPacket = storedTextPacket(id = 822, status = MessageStatus.QUEUED)
+ var lookups = 0
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } calls
+ {
+ lookups++
+ if (lookups == 1) null else PersistedPacket(PERSISTED_ID, storedPacket)
+ }
+ handler.sendToRadio(MeshPacket(id = 820))
+ val queued = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 822, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, emoji = 1)),
+ )
+ }
+ testScheduler.runCurrent()
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.PERSISTED_STATUS_RETRY_DELAY.inWholeMilliseconds)
+ testScheduler.runCurrent()
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, queued.await().status)
+ assertEquals(2, lookups)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ verifySuspend(exactly(0)) { packetRepository.applyOutgoingReactionQueueStatus(any(), any()) }
+ }
+
+ @Test
+ fun `queue stop waits briefly for a persisted reaction row`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedReaction =
+ Reaction(
+ replyId = 1,
+ user = User(id = "!00000001"),
+ emoji = "👍",
+ timestamp = 0,
+ snr = null,
+ rssi = null,
+ hopsAway = 0,
+ packetId = 825,
+ status = MessageStatus.QUEUED,
+ )
+ var lookups = 0
+ everySuspend { packetRepository.applyOutgoingReactionQueueStatus(825, MessageStatus.ERROR) } calls
+ {
+ lookups++
+ if (lookups == 1) null else PersistedReaction(PERSISTED_REACTION_ID, storedReaction)
+ }
+
+ handler.sendToRadio(MeshPacket(id = 820))
+ val queued = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 825, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, reply_id = 1, emoji = 1)),
+ )
+ }
+ testScheduler.runCurrent()
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.PERSISTED_STATUS_RETRY_DELAY.inWholeMilliseconds)
+ testScheduler.runCurrent()
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, queued.await().status)
+ assertEquals(2, lookups)
+ verifySuspend { packetRepository.applyOutgoingReactionQueueStatus(825, MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `dispatch status does not overwrite a terminal routing result`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedPacket = storedTextPacket(id = 823, status = MessageStatus.DELIVERED)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+
+ handler.sendToRadio(MeshPacket(id = 823, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)))
+ testScheduler.runCurrent()
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 823, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ testScheduler.advanceTimeBy(PacketHandlerImpl.SEND_ACK_TIMEOUT + 1.seconds)
+ testScheduler.runCurrent()
+ verifySuspend(exactly(0)) { packetRepository.timeOutEnroutePacket(PERSISTED_ID, any()) }
+ }
+
+ @Test
+ fun `queue stop does not overwrite a terminal routing result`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedPacket = storedTextPacket(id = 824, status = MessageStatus.DELIVERED)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+
+ handler.sendToRadio(MeshPacket(id = 820))
+ val queued = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 824, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ testScheduler.runCurrent()
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, queued.await().status)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ testScheduler.advanceTimeBy(PacketHandlerImpl.SEND_ACK_TIMEOUT + 1.seconds)
+ testScheduler.runCurrent()
+ verifySuspend(exactly(0)) { packetRepository.timeOutEnroutePacket(PERSISTED_ID, any()) }
+ }
+
+ @Test
+ fun `stopping the queue reports an awaited packet that was already dispatched`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ // With no backlog, packet 807 reaches the transport before stopPacketQueue() drains its response.
+ val storedPacket = storedTextPacket(id = 807, status = MessageStatus.ENROUTE)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+ val result = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 807, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ testScheduler.runCurrent()
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ val stopped = result.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertTrue(stopped.dispatched)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `queue stop after queue acceptance reports missing routing completion`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedPacket = storedTextPacket(id = 817, status = MessageStatus.ENROUTE)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+ val result = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 817, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 817, res = 0, free = 16))
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ val stopped = result.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertTrue(stopped.dispatched)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `disconnect drains queued responses without restarting the processor`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val first = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 813)) }
+ val queued = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 814)) }
+ testScheduler.runCurrent()
+
+ connectionStateFlow.value = ConnectionState.Disconnected
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 813, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ val firstStopped = first.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, firstStopped.status)
+ assertTrue(firstStopped.dispatched)
+ val stopped = queued.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertFalse(stopped.dispatched)
+ verify(exactly(1)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `missing queue status does not terminate an admitted routing waiter`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 805)) }
+ testScheduler.runCurrent()
+
+ testScheduler.advanceTimeBy(responseTimeoutCrossingMillis)
+ testScheduler.runCurrent()
+
+ assertFalse(result.isCompleted)
+ handler.completeDispatchedResponse(805, complete = true)
+ assertEquals(AwaitedSendStatus.ACCEPTED, result.await().status)
+ }
+
+ @Test
+ fun `late queue rejection still terminates a strict waiter after confirmation timeout`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 824)) }
+ testScheduler.runCurrent()
+
+ testScheduler.advanceTimeBy(responseTimeoutCrossingMillis)
+ testScheduler.runCurrent()
+ assertFalse(result.isCompleted)
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 824, res = 33, free = 16))
+ testScheduler.runCurrent()
+
+ val rejected = result.await()
+ assertEquals(AwaitedSendStatus.RADIO_REJECTED, rejected.status)
+ assertTrue(rejected.dispatched)
+ }
+
+ @Test
+ fun `missing queue status does not fail a dispatched persisted packet`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val packet = MeshPacket(id = 806, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+ everySuspend { packetRepository.applyOutgoingQueueStatus(packet, MessageStatus.ENROUTE) } returns
+ PersistedPacket(PERSISTED_ID, storedTextPacket(id = 806, status = MessageStatus.QUEUED))
+
+ assertTrue(handler.sendToRadio(packet))
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(responseTimeoutCrossingMillis)
+ testScheduler.runCurrent()
+
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(packet, MessageStatus.ENROUTE) }
+ verifySuspend(exactly(0)) { packetRepository.applyOutgoingQueueStatus(packet, MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `disconnected queue admission reports transport stopped`() = runTest(testDispatcher) {
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 805)) }
+ testScheduler.runCurrent()
+
+ val stopped = result.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertFalse(stopped.dispatched)
+ verify(exactly(0)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `disconnected fire and forget admission is rejected`() = runTest(testDispatcher) {
+ assertFalse(handler.sendToRadio(MeshPacket(id = 806)))
+
+ verify(exactly(0)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `transport refusing dispatch completes awaiting caller with failure`() = runTest(testDispatcher) {
+ every { radioInterfaceService.trySendToRadio(any()) } returns false
+ connectionStateFlow.value = ConnectionState.Connected
+ val storedPacket = storedTextPacket(id = 808, status = MessageStatus.QUEUED)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+ val result = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 808, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ testScheduler.runCurrent()
+
+ val failed = result.await()
+ assertEquals(AwaitedSendStatus.SEND_FAILED, failed.status)
+ assertFalse(failed.dispatched)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+ }
+
+ @Test
+ fun `queue send failure completes awaiting caller with failure`() = runTest(testDispatcher) {
+ every { radioInterfaceService.trySendToRadio(any()) } throws IllegalStateException("test send failure")
+ connectionStateFlow.value = ConnectionState.Connected
+
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 806)) }
+ testScheduler.runCurrent()
+
+ val failed = result.await()
+ assertEquals(AwaitedSendStatus.SEND_FAILED, failed.status)
+ assertFalse(failed.dispatched)
+ }
+
+ @Test
+ fun `queue status without a packet id does not satisfy strict routing waiters`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val first = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 820)) }
+ val second = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 821)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 0, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ assertFalse(first.isCompleted, "QueueStatus must not satisfy the first routing waiter")
+ assertFalse(second.isCompleted)
+ verify(exactly(2)) { radioInterfaceService.trySendToRadio(any()) }
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 0, res = 0, free = 16))
+ testScheduler.runCurrent()
+ assertFalse(second.isCompleted, "QueueStatus must not satisfy the second routing waiter")
+
+ handler.completeDispatchedResponse(dataRequestId = 820, complete = true)
+ handler.completeDispatchedResponse(dataRequestId = 821, complete = true)
+
+ assertEquals(AwaitedSendStatus.ACCEPTED, first.await().status)
+ assertEquals(AwaitedSendStatus.ACCEPTED, second.await().status)
+ }
+
+ @Test
+ fun `queue resumes after a stop and a later send`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+
+ assertTrue(handler.sendToRadio(MeshPacket(id = 822)))
+ testScheduler.runCurrent()
+
+ verify(exactly(1)) { radioInterfaceService.trySendToRadio(any()) }
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 822, res = 0, free = 16))
+ testScheduler.runCurrent()
+ }
+
+ @Test
+ fun `cancelled queue handoff releases its reservation before restarting queued work`() = runTest(testDispatcher) {
+ val storedPacket = storedTextPacket(id = 815, status = MessageStatus.QUEUED)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, storedPacket)
+ var sendAttempts = 0
+ every { radioInterfaceService.trySendToRadio(any()) } calls
+ {
+ sendAttempts++
+ if (sendAttempts == 1) throw CancellationException("transport stopped")
+ true
+ }
+ connectionStateFlow.value = ConnectionState.Connected
+
+ val interrupted = async {
+ handler.sendToRadioAndAwaitResult(
+ MeshPacket(id = 815, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)),
+ )
+ }
+ val queued = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 816)) }
+ testScheduler.runCurrent()
+
+ val stopped = interrupted.await()
+ assertEquals(AwaitedSendStatus.TRANSPORT_STOPPED, stopped.status)
+ assertFalse(stopped.dispatched)
+ verifySuspend { packetRepository.applyOutgoingQueueStatus(any(), MessageStatus.ERROR) }
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 816, res = 0, free = 16))
+ testScheduler.runCurrent()
+ handler.completeDispatchedResponse(dataRequestId = 816, complete = true)
+ val accepted = queued.await()
+ assertEquals(AwaitedSendStatus.ACCEPTED, accepted.status)
+ assertTrue(accepted.dispatched)
+
+ assertTrue(handler.sendToRadio(MeshPacket(id = 815)))
+ testScheduler.runCurrent()
+ verify(exactly(3)) { radioInterfaceService.trySendToRadio(any()) }
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 815, res = 0, free = 16))
+ testScheduler.runCurrent()
+ }
+
+ @Test
+ fun `awaited packet without an id is rejected before dispatch`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+
+ val result = handler.sendToRadioAndAwaitResult(MeshPacket())
+
+ assertEquals(AwaitedSendStatus.REJECTED, result.status)
+ assertFalse(result.dispatched)
+ assertFalse(handler.sendToRadioAndAwait(MeshPacket()), "the Boolean compatibility API must map rejection")
+ verify(exactly(0)) { radioInterfaceService.trySendToRadio(any()) }
+ }
+
+ @Test
+ fun `duplicate awaited packet id is rejected without replacing the original waiter`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val original = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 809)) }
+ testScheduler.runCurrent()
+
+ val duplicate = handler.sendToRadioAndAwaitResult(MeshPacket(id = 809))
+
+ assertEquals(AwaitedSendStatus.REJECTED, duplicate.status)
+ assertFalse(duplicate.dispatched)
+ assertFalse(original.isCompleted)
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 809, res = 0, free = 16))
+ testScheduler.runCurrent()
+ assertFalse(original.isCompleted)
+ handler.completeDispatchedResponse(dataRequestId = 809, complete = true)
+
+ val accepted = original.await()
+ assertEquals(AwaitedSendStatus.ACCEPTED, accepted.status)
+ assertTrue(accepted.dispatched)
+ }
+
+ @Test
+ fun `response received before dispatch is ignored and retains the queued id`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ handler.sendToRadio(MeshPacket(id = 816))
+ val queued = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 817)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 817, res = 0, free = 16))
+ handler.completeDispatchedResponse(dataRequestId = 817, complete = true)
+ testScheduler.runCurrent()
+
+ assertFalse(queued.isCompleted)
+
+ val duplicate = handler.sendToRadioAndAwaitResult(MeshPacket(id = 817))
+ assertEquals(AwaitedSendStatus.REJECTED, duplicate.status)
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 816, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ verify(exactly(2)) { radioInterfaceService.trySendToRadio(any()) }
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 817, res = 0, free = 16))
+ testScheduler.runCurrent()
+ assertFalse(queued.isCompleted)
+ handler.completeDispatchedResponse(dataRequestId = 817, complete = true)
+
+ val accepted = queued.await()
+ assertEquals(AwaitedSendStatus.ACCEPTED, accepted.status)
+ assertTrue(accepted.dispatched)
+ assertTrue(handler.sendToRadio(MeshPacket(id = 817)))
+ testScheduler.runCurrent()
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 817, res = 0, free = 16))
+ testScheduler.runCurrent()
+ }
+
+ @Test
+ fun `routing rejection after dispatch completes awaited response as radio rejected`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 818)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 818, res = 0, free = 16))
+ testScheduler.runCurrent()
+ handler.completeDispatchedResponse(dataRequestId = 818, complete = false)
+ testScheduler.runCurrent()
+
+ val rejected = result.await()
+ assertEquals(AwaitedSendStatus.RADIO_REJECTED, rejected.status)
+ assertTrue(rejected.dispatched)
+ }
+
+ @Test
+ fun `late queue status cannot replace an already completed response`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 819)) }
+ testScheduler.runCurrent()
+
+ handler.completeDispatchedResponse(dataRequestId = 819, complete = true)
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 819, res = 33, free = 16))
+ testScheduler.runCurrent()
+
+ val accepted = result.await()
+ assertEquals(AwaitedSendStatus.ACCEPTED, accepted.status)
+ assertTrue(accepted.dispatched)
+ }
+
+ @Test
+ fun `cancelling an awaiting caller does not release its queued packet id`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ handler.sendToRadio(MeshPacket(id = 811))
+ val awaiting = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 812)) }
+ testScheduler.runCurrent()
+
+ awaiting.cancelAndJoin()
+ val duplicate = handler.sendToRadioAndAwaitResult(MeshPacket(id = 812))
+
+ assertEquals(AwaitedSendStatus.REJECTED, duplicate.status)
+ assertFalse(duplicate.dispatched)
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+ }
+
+ @Test
+ fun `service owned routing expiry times out an active strict waiter`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 812)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 812, res = 0, free = 16))
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.ROUTING_RESPONSE_TIMEOUT.inWholeMilliseconds)
+ testScheduler.runCurrent()
+
+ val timedOut = result.await()
+ assertEquals(AwaitedSendStatus.TIMED_OUT, timedOut.status)
+ assertTrue(timedOut.dispatched)
+ }
+
+ @Test
+ fun `cancelled strict await releases its packet id after service owned routing expiry`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val awaiting = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 812)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 812, res = 0, free = 16))
+ testScheduler.runCurrent()
+ awaiting.cancelAndJoin()
+
+ val duplicate = handler.sendToRadioAndAwaitResult(MeshPacket(id = 812))
+ assertEquals(AwaitedSendStatus.REJECTED, duplicate.status)
+ assertFalse(duplicate.dispatched)
+
+ testScheduler.advanceTimeBy(PacketHandlerImpl.ROUTING_RESPONSE_TIMEOUT.inWholeMilliseconds)
+ testScheduler.runCurrent()
+
+ val retry = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 812)) }
+ testScheduler.runCurrent()
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 812, res = 0, free = 16))
+ handler.completeDispatchedResponse(dataRequestId = 812, complete = true)
+ testScheduler.runCurrent()
+
+ val accepted = retry.await()
+ assertEquals(AwaitedSendStatus.ACCEPTED, accepted.status)
+ assertTrue(accepted.dispatched)
+ }
+
+ @Test
+ fun `fire and forget rejects invalid packet ids without throwing or replacing queued work`() =
+ runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ assertTrue(handler.sendToRadio(MeshPacket(id = 810)))
+ testScheduler.runCurrent()
+
+ assertFalse(handler.sendToRadio(MeshPacket(id = 810)))
+ assertFalse(handler.sendToRadio(MeshPacket()))
+ testScheduler.runCurrent()
+
+ verify(exactly(1)) { radioInterfaceService.trySendToRadio(any()) }
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
+ }
+
+ @Test
+ fun `completed packet id can be reused by a later retry`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ assertTrue(handler.sendToRadio(MeshPacket(id = 810)))
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 810, res = 0, free = 16))
+ testScheduler.runCurrent()
+
+ assertTrue(handler.sendToRadio(MeshPacket(id = 810)))
+ testScheduler.runCurrent()
+
+ verify(exactly(2)) { radioInterfaceService.trySendToRadio(any()) }
+
+ handler.stopPacketQueue()
+ testScheduler.runCurrent()
}
@Test
@@ -207,7 +952,7 @@ class PacketHandlerImplTest {
@Test
fun `strict await fails immediately when transport send throws`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
- every { radioInterfaceService.sendToRadio(any()) } throws IllegalStateException("transport failed")
+ every { radioInterfaceService.trySendToRadio(any()) } throws IllegalStateException("transport failed")
val result = async { handler.sendToRadioAndAwait(MeshPacket(id = 797)) }
testScheduler.runCurrent()
@@ -228,10 +973,26 @@ class PacketHandlerImplTest {
assertFalse(result.isCompleted)
- handler.removeResponse(793, complete = true)
+ handler.completeDispatchedResponse(793, complete = true)
assertTrue(result.await())
}
+ @Test
+ fun `strict routing wait outlives the local queue response window`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val result = async { handler.sendToRadioAndAwaitResult(MeshPacket(id = 822)) }
+ testScheduler.runCurrent()
+
+ handler.handleQueueStatus(QueueStatus(mesh_packet_id = 822, res = 0, free = 16))
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.RESPONSE_TIMEOUT + 1.seconds)
+ testScheduler.runCurrent()
+
+ assertFalse(result.isCompleted)
+ handler.completeDispatchedResponse(822, complete = true)
+ assertEquals(AwaitedSendStatus.ACCEPTED, result.await().status)
+ }
+
@Test
fun `strict await succeeds on routing ack`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
@@ -243,13 +1004,13 @@ class PacketHandlerImplTest {
assertFalse(result.isCompleted)
- handler.removeResponse(794, complete = true)
+ handler.completeDispatchedResponse(794, complete = true)
assertTrue(result.await())
}
@Test
- fun `zero id queue status completes only its correlated routing response`() = runTest(testDispatcher) {
+ fun `zero id synchronous loopback completes only the active queue entry`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
val awaitingRoutingAck = async { handler.sendToRadioAndAwait(MeshPacket(id = 798)) }
@@ -266,7 +1027,7 @@ class PacketHandlerImplTest {
assertTrue(synchronousLoopback.await())
assertFalse(awaitingRoutingAck.isCompleted)
- handler.removeResponse(798, complete = true)
+ handler.completeDispatchedResponse(798, complete = true)
assertTrue(awaitingRoutingAck.await())
}
@@ -279,7 +1040,7 @@ class PacketHandlerImplTest {
handler.handleQueueStatus(QueueStatus(mesh_packet_id = 795, res = 0, free = 16))
testScheduler.runCurrent()
- handler.removeResponse(795, complete = false)
+ handler.completeDispatchedResponse(795, complete = false)
assertFalse(result.await())
}
@@ -306,14 +1067,36 @@ class PacketHandlerImplTest {
verifySuspend { meshLogRepository.insert(any()) }
}
- private fun enrouteDataPacket(id: Int, time: Long = 0L) =
- DataPacket(to = "!12345678", bytes = null, dataType = 1, id = id, time = time, status = MessageStatus.ENROUTE)
+ private fun storedTextPacket(id: Int, status: MessageStatus) =
+ DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, id = id, status = status)
+
+ private fun enrouteDataPacket(id: Int, time: Long = 0L, status: MessageStatus = MessageStatus.ENROUTE) =
+ DataPacket(to = "!12345678", bytes = null, dataType = 1, id = id, time = time, status = status)
+
+ private fun reaction(id: Int, timestamp: Long = 0L, status: MessageStatus = MessageStatus.ENROUTE) = Reaction(
+ replyId = 1,
+ user = User(id = "!00000001"),
+ emoji = "👍",
+ timestamp = timestamp,
+ snr = null,
+ rssi = null,
+ hopsAway = 0,
+ packetId = id,
+ status = status,
+ )
+
+ private fun outboundDataPacket(id: Int) = MeshPacket(id = id, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+
+ private fun outboundReaction(id: Int) =
+ MeshPacket(id = id, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, reply_id = 1, emoji = 1))
@Test
fun `unacked ENROUTE send times out to a retryable ERROR TIMEOUT`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, enrouteDataPacket(123, status = MessageStatus.QUEUED))
- handler.sendToRadio(ToRadio(packet = MeshPacket(id = 123)))
+ handler.sendToRadio(ToRadio(packet = outboundDataPacket(123)))
testScheduler.advanceTimeBy(PacketHandlerImpl.SEND_ACK_TIMEOUT + 1.seconds)
testScheduler.runCurrent()
@@ -323,18 +1106,39 @@ class PacketHandlerImplTest {
@Test
fun `the timeout never fires before its deadline`() = runTest(testDispatcher) {
connectionStateFlow.value = ConnectionState.Connected
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, enrouteDataPacket(124, status = MessageStatus.QUEUED))
- handler.sendToRadio(ToRadio(packet = MeshPacket(id = 124)))
+ handler.sendToRadio(ToRadio(packet = outboundDataPacket(124)))
testScheduler.advanceTimeBy(PacketHandlerImpl.SEND_ACK_TIMEOUT - 1.seconds)
testScheduler.runCurrent()
verifySuspend(exactly(0)) { packetRepository.timeOutEnroutePacket(any(), any()) }
}
+ @Test
+ fun `unacked ENROUTE reaction times out to a retryable ERROR TIMEOUT`() = runTest(testDispatcher) {
+ connectionStateFlow.value = ConnectionState.Connected
+ val queued = reaction(id = 125, status = MessageStatus.QUEUED)
+ everySuspend { packetRepository.applyOutgoingReactionQueueStatus(125, MessageStatus.ENROUTE) } returns
+ PersistedReaction(PERSISTED_REACTION_ID, queued)
+
+ handler.sendToRadio(ToRadio(packet = outboundReaction(125)))
+ testScheduler.runCurrent()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.SEND_ACK_TIMEOUT + 1.seconds)
+ testScheduler.runCurrent()
+
+ verifySuspend { packetRepository.applyOutgoingReactionQueueStatus(125, MessageStatus.ENROUTE) }
+ verifySuspend {
+ packetRepository.timeOutEnrouteReaction(PERSISTED_REACTION_ID, Routing.Error.TIMEOUT.value)
+ }
+ }
+
@Test
fun `rearm times out a stale persisted ENROUTE packet after the reconnect grace`() = runTest(testDispatcher) {
val stale = enrouteDataPacket(321, time = 0L)
everySuspend { packetRepository.getEnroutePackets() } returns listOf(PersistedPacket(PERSISTED_ID, stale))
+ everySuspend { packetRepository.getEnrouteReactions() } returns emptyList()
handler.rearmSendAckTimeouts()
testScheduler.advanceTimeBy(PacketHandlerImpl.REARM_GRACE + 1.seconds)
@@ -343,10 +1147,44 @@ class PacketHandlerImplTest {
verifySuspend { packetRepository.timeOutEnroutePacket(PERSISTED_ID, Routing.Error.TIMEOUT.value) }
}
+ @Test
+ fun `rearm times out a stale persisted ENROUTE reaction after the reconnect grace`() = runTest(testDispatcher) {
+ val stale = reaction(id = 323, timestamp = 0L)
+ everySuspend { packetRepository.getEnroutePackets() } returns emptyList()
+ everySuspend { packetRepository.getEnrouteReactions() } returns
+ listOf(PersistedReaction(PERSISTED_REACTION_ID, stale))
+
+ handler.rearmSendAckTimeouts()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.REARM_GRACE + 1.seconds)
+ testScheduler.runCurrent()
+
+ verifySuspend {
+ packetRepository.timeOutEnrouteReaction(PERSISTED_REACTION_ID, Routing.Error.TIMEOUT.value)
+ }
+ }
+
+ @Test
+ fun `rearm keeps independent reaction timers when mesh packet ids collide`() = runTest(testDispatcher) {
+ val firstId = PERSISTED_REACTION_ID.copy(replyId = 1, emoji = "👍")
+ val secondId = PERSISTED_REACTION_ID.copy(replyId = 2, emoji = "❤️")
+ val first = PersistedReaction(firstId, reaction(id = 324, timestamp = 0L))
+ val second = PersistedReaction(secondId, reaction(id = 324, timestamp = 0L).copy(emoji = "❤️"))
+ everySuspend { packetRepository.getEnroutePackets() } returns emptyList()
+ everySuspend { packetRepository.getEnrouteReactions() } returns listOf(first, second)
+
+ handler.rearmSendAckTimeouts()
+ testScheduler.advanceTimeBy(PacketHandlerImpl.REARM_GRACE + 1.seconds)
+ testScheduler.runCurrent()
+
+ verifySuspend { packetRepository.timeOutEnrouteReaction(firstId, Routing.Error.TIMEOUT.value) }
+ verifySuspend { packetRepository.timeOutEnrouteReaction(secondId, Routing.Error.TIMEOUT.value) }
+ }
+
@Test
fun `rearm gives a fresh ENROUTE packet its full ack window`() = runTest(testDispatcher) {
val fresh = enrouteDataPacket(322, time = nowMillis)
everySuspend { packetRepository.getEnroutePackets() } returns listOf(PersistedPacket(PERSISTED_ID, fresh))
+ everySuspend { packetRepository.getEnrouteReactions() } returns emptyList()
handler.rearmSendAckTimeouts()
testScheduler.advanceTimeBy(PacketHandlerImpl.REARM_GRACE + 1.seconds)
@@ -364,9 +1202,12 @@ class PacketHandlerImplTest {
// fire on its own original deadline.
connectionStateFlow.value = ConnectionState.Connected
val packet = enrouteDataPacket(325, time = nowMillis)
+ everySuspend { packetRepository.applyOutgoingQueueStatus(any(), any()) } returns
+ PersistedPacket(PERSISTED_ID, packet)
everySuspend { packetRepository.getEnroutePackets() } returns listOf(PersistedPacket(PERSISTED_ID, packet))
+ everySuspend { packetRepository.getEnrouteReactions() } returns emptyList()
- handler.sendToRadio(ToRadio(packet = MeshPacket(id = 325)))
+ handler.sendToRadio(ToRadio(packet = outboundDataPacket(325)))
testScheduler.runCurrent()
repeat(3) {
handler.rearmSendAckTimeouts()
@@ -388,6 +1229,7 @@ class PacketHandlerImplTest {
val first = PersistedPacket(firstId, enrouteDataPacket(id = 326, time = 0L))
val second = PersistedPacket(secondId, enrouteDataPacket(id = 326, time = 0L))
everySuspend { packetRepository.getEnroutePackets() } returns listOf(first, second)
+ everySuspend { packetRepository.getEnrouteReactions() } returns emptyList()
handler.rearmSendAckTimeouts()
testScheduler.advanceTimeBy(PacketHandlerImpl.REARM_GRACE + 1.seconds)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 8f2e629711..2cc820e009 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -313,18 +313,53 @@ interface PacketDao {
}
/**
- * Updates only an unambiguous row matching the identity available on an outgoing protobuf packet. Mesh packet IDs
+ * Resolves only an unambiguous row matching the identity available on an outgoing protobuf packet. Mesh packet IDs
* are sender-scoped, so ID-only lookup can select an inbound packet or an unrelated command with the same ID.
*/
@Transaction
- suspend fun updateOutgoingMessageStatus(packet: MeshPacket, status: MessageStatus): Packet? {
+ suspend fun resolveOutgoingPacket(packet: MeshPacket): Packet? =
+ outgoingCandidates(packet, findPacketsWithId(packet.id)).singleOrNull()
+
+ /**
+ * Resolves and conditionally applies a queue-stage status without racing a terminal ACK/NAK update.
+ *
+ * @return the resolved row as read before the update, or null when no unambiguous outgoing row matches.
+ */
+ @Transaction
+ suspend fun applyOutgoingQueueStatus(packet: MeshPacket, status: MessageStatus): Packet? {
+ val match = resolveOutgoingPacket(packet) ?: return null
+ if (shouldApplyOutgoingQueueStatus(match.data.status, status)) {
+ update(match.copy(data = match.data.copy(status = status)))
+ }
+ return match
+ }
+
+ /**
+ * Reaction equivalent of [applyOutgoingQueueStatus], restricted to one unambiguous outgoing row.
+ *
+ * @return the resolved reaction as read before the update, or null when no unambiguous non-received row matches.
+ */
+ @Transaction
+ suspend fun applyOutgoingReactionQueueStatus(packetId: Int, status: MessageStatus): ReactionEntity? {
+ val match =
+ findReactionsWithId(packetId).filter { it.status != MessageStatus.RECEIVED }.singleOrNull() ?: return null
+ if (shouldApplyOutgoingQueueStatus(match.status, status)) update(match.copy(status = status))
+ return match
+ }
+
+ private fun outgoingCandidates(packet: MeshPacket, stored: List<Packet>): List<Packet> {
val portNum = packet.decoded?.portnum?.value
- val matches =
- findPacketsWithId(packet.id).filter { stored ->
- stored.data.from.matchesNodeNum(packet.from, packet.from) &&
- stored.data.to.matchesNodeNum(packet.to, packet.from) &&
- (portNum == null || stored.data.dataType == portNum)
- }
+ return stored.filter {
+ it.data.from.matchesNodeNum(packet.from, packet.from) &&
+ it.data.to.matchesNodeNum(packet.to, packet.from) &&
+ (portNum == null || it.data.dataType == portNum)
+ }
+ }
+
+ /** Updates the unique outgoing row, preferring the only candidate already at [status] when duplicates exist. */
+ @Transaction
+ suspend fun updateOutgoingMessageStatus(packet: MeshPacket, status: MessageStatus): Packet? {
+ val matches = outgoingCandidates(packet, findPacketsWithId(packet.id))
val alreadyAtStatus = matches.filter { it.data.status == status }
val match =
when {
@@ -530,6 +565,15 @@ interface PacketDao {
)
suspend fun getReactionByPacketId(packetId: Int): ReactionEntity?
+ @Query(
+ """
+ SELECT * FROM reactions
+ WHERE status = :status
+ AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ """,
+ )
+ suspend fun getReactionsByStatus(status: MessageStatus): List<ReactionEntity>
+
@Transaction
@Query(
"""
@@ -754,6 +798,48 @@ interface PacketDao {
return canTimeOut
}
+ @Query(
+ """
+ UPDATE reactions
+ SET status = :failedStatus, routing_error = :routingError
+ WHERE myNodeNum = :myNodeNum
+ AND reply_id = :replyId
+ AND user_id = :userId
+ AND emoji = :emoji
+ AND status = :enrouteStatus
+ """,
+ )
+ suspend fun updateEnrouteReactionStatus(
+ myNodeNum: Int,
+ replyId: Int,
+ userId: String,
+ emoji: String,
+ routingError: Int,
+ enrouteStatus: MessageStatus,
+ failedStatus: MessageStatus,
+ ): Int
+
+ /**
+ * Atomically stamps [routingError] on a sent reaction only while it is still [MessageStatus.ENROUTE].
+ *
+ * @return true if a row was timed out.
+ */
+ suspend fun timeOutEnrouteReaction(
+ myNodeNum: Int,
+ replyId: Int,
+ userId: String,
+ emoji: String,
+ routingError: Int,
+ ): Boolean = updateEnrouteReactionStatus(
+ myNodeNum = myNodeNum,
+ replyId = replyId,
+ userId = userId,
+ emoji = emoji,
+ routingError = routingError,
+ enrouteStatus = MessageStatus.ENROUTE,
+ failedStatus = MessageStatus.ERROR,
+ ) == 1
+
/**
* Atomically finds reactions by [replacement]'s packetId + userId + emoji and updates every ownership-scoped copy,
* borrowing [myNodeNum][ReactionEntity.myNodeNum] from each existing row. No-op if no match is found.
@@ -920,3 +1006,21 @@ private fun String?.matchesNodeNum(nodeNum: Int, localNodeNum: Int): Boolean =
is NodeAddress.ByNum -> address.num == nodeNum
is NodeAddress.ById -> false
}
+
+/**
+ * Queue-stage status guard shared with callers that decide whether to arm follow-up work.
+ *
+ * Only [MessageStatus.ENROUTE] and [MessageStatus.ERROR] are owned by this transition. Terminal routing and SFPP
+ * statuses are applied by their dedicated state transitions.
+ */
+fun shouldApplyOutgoingQueueStatus(current: MessageStatus?, status: MessageStatus): Boolean = when (status) {
+ MessageStatus.ENROUTE -> current == null || current == MessageStatus.UNKNOWN || current == MessageStatus.QUEUED
+
+ MessageStatus.ERROR ->
+ current == null ||
+ current == MessageStatus.UNKNOWN ||
+ current == MessageStatus.QUEUED ||
+ current == MessageStatus.ENROUTE
+
+ else -> false
+}
diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
index 030841341d..c30974fe6d 100644
--- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
@@ -28,6 +28,8 @@ import org.meshtastic.core.database.getInMemoryDatabaseBuilder
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.proto.Data
+import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import kotlin.test.AfterTest
@@ -35,6 +37,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
+import kotlin.test.assertNull
import kotlin.test.assertTrue
abstract class CommonPacketDaoTest {
@@ -93,7 +96,7 @@ abstract class CommonPacketDaoTest {
@AfterTest
fun closeDb() {
- database.close()
+ if (::database.isInitialized) database.close()
}
@Test
@@ -173,6 +176,26 @@ abstract class CommonPacketDaoTest {
),
)
+ private suspend fun insertSentReaction(
+ packetId: Int,
+ status: MessageStatus,
+ ownerNodeNum: Int = myNodeNum,
+ replyId: Int = packetId,
+ emoji: String = "👍",
+ ) {
+ packetDao.insert(
+ ReactionEntity(
+ myNodeNum = ownerNodeNum,
+ replyId = replyId,
+ userId = "!local",
+ emoji = emoji,
+ timestamp = nowMillis,
+ packetId = packetId,
+ status = status,
+ ),
+ )
+ }
+
@Test
fun timeOutEnroutePacketFailsOnlyStillEnroutePackets() = runTest {
createDb()
@@ -200,6 +223,105 @@ abstract class CommonPacketDaoTest {
assertEquals(MessageStatus.DELIVERED, untouched.packet.data.status)
}
+ @Test
+ fun applyOutgoingQueueStatusLeavesAnAlreadyResolvedPacketAlone() = runTest {
+ createDb()
+ insertSentPacket(packetId = 8009, status = MessageStatus.DELIVERED)
+ val outgoing =
+ MeshPacket(
+ from = myNodeNum,
+ to = NodeAddress.NODENUM_BROADCAST,
+ id = 8009,
+ decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP),
+ )
+
+ val prior = assertNotNull(packetDao.applyOutgoingQueueStatus(outgoing, MessageStatus.ENROUTE))
+
+ assertEquals(MessageStatus.DELIVERED, prior.data.status)
+ assertEquals(MessageStatus.DELIVERED, packetDao.getPacketByPacketId(8009)?.packet?.data?.status)
+ }
+
+ @Test
+ fun applyOutgoingReactionQueueStatusIgnoresAmbiguousMeshIds() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8011, replyId = 1, emoji = "👍", status = MessageStatus.QUEUED)
+ insertSentReaction(packetId = 8011, replyId = 2, emoji = "❤️", status = MessageStatus.QUEUED)
+
+ assertNull(packetDao.applyOutgoingReactionQueueStatus(8011, MessageStatus.ENROUTE))
+
+ val reactions = packetDao.findReactionsWithId(8011).associateBy { it.replyId }
+ assertEquals(MessageStatus.QUEUED, reactions.getValue(1).status)
+ assertEquals(MessageStatus.QUEUED, reactions.getValue(2).status)
+ }
+
+ @Test
+ fun queueStatusGuardRejectsStatusesOwnedByOtherTransitions() {
+ assertFalse(shouldApplyOutgoingQueueStatus(MessageStatus.DELIVERED, MessageStatus.QUEUED))
+ assertFalse(shouldApplyOutgoingQueueStatus(MessageStatus.SFPP_CONFIRMED, MessageStatus.SFPP_ROUTING))
+ }
+
+ @Test
+ fun getReactionsByStatusReturnsOnlyOwnedRowsWithThatStatus() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8005, status = MessageStatus.ENROUTE)
+ insertSentReaction(packetId = 8006, status = MessageStatus.DELIVERED)
+ insertSentReaction(packetId = 8007, status = MessageStatus.ENROUTE, ownerNodeNum = myNodeNum + 1)
+
+ val enroute = packetDao.getReactionsByStatus(MessageStatus.ENROUTE)
+
+ assertEquals(listOf(8005), enroute.map { it.packetId })
+ }
+
+ @Test
+ fun timeOutEnrouteReactionFailsOnlyStillEnrouteReactions() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8003, status = MessageStatus.ENROUTE)
+
+ assertTrue(packetDao.timeOutEnrouteReaction(myNodeNum, 8003, "!local", "👍", TIMEOUT_ROUTING_ERROR))
+
+ val timedOut = packetDao.getReactionByPacketId(8003)
+ assertNotNull(timedOut)
+ assertEquals(MessageStatus.ERROR, timedOut.status)
+ assertEquals(TIMEOUT_ROUTING_ERROR, timedOut.routingError)
+ }
+
+ @Test
+ fun timeOutEnrouteReactionLeavesAnAlreadyResolvedReactionAlone() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8004, status = MessageStatus.DELIVERED)
+
+ assertFalse(packetDao.timeOutEnrouteReaction(myNodeNum, 8004, "!local", "👍", TIMEOUT_ROUTING_ERROR))
+
+ val untouched = packetDao.getReactionByPacketId(8004)
+ assertNotNull(untouched)
+ assertEquals(MessageStatus.DELIVERED, untouched.status)
+ assertEquals(0, untouched.routingError)
+ }
+
+ @Test
+ fun applyOutgoingReactionQueueStatusLeavesAnAlreadyResolvedReactionAlone() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8010, status = MessageStatus.DELIVERED)
+
+ val prior = assertNotNull(packetDao.applyOutgoingReactionQueueStatus(8010, MessageStatus.ENROUTE))
+
+ assertEquals(MessageStatus.DELIVERED, prior.status)
+ assertEquals(MessageStatus.DELIVERED, packetDao.getReactionByPacketId(8010)?.status)
+ }
+
+ @Test
+ fun timeOutEnrouteReactionTargetsOnlyOnePersistedRowWhenMeshIdsCollide() = runTest {
+ createDb()
+ insertSentReaction(packetId = 8008, replyId = 1, emoji = "👍", status = MessageStatus.ENROUTE)
+ insertSentReaction(packetId = 8008, replyId = 2, emoji = "❤️", status = MessageStatus.ENROUTE)
+
+ assertTrue(packetDao.timeOutEnrouteReaction(myNodeNum, 1, "!local", "👍", TIMEOUT_ROUTING_ERROR))
+
+ val reactions = packetDao.findReactionsWithId(8008).associateBy { it.replyId }
+ assertEquals(MessageStatus.ERROR, reactions.getValue(1).status)
+ assertEquals(MessageStatus.ENROUTE, reactions.getValue(2).status)
+ }
+
@Test
fun timeOutEnroutePacketTargetsOnlyOnePersistedRowWhenMeshIdsCollide() = runTest {
createDb()
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
index 7d026ead2e..536cc3a6a7 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
@@ -49,6 +49,13 @@ constructor(
* @param destNum The destination node number.
* @param profile The device profile to install.
* @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
+ * @throws org.meshtastic.core.repository.PacketQueueRejectedException when any profile write is refused locally.
+ * @throws org.meshtastic.core.repository.EditSettingsTransactionException when the begin or commit boundary is
+ * refused locally.
+ * @throws org.meshtastic.core.repository.LocalNodeUnavailableException when a fixed-position profile is installed
+ * before the local node identity is available.
+ * @throws MalformedMeshtasticUrlException when the profile channel URL or channel set is invalid. This is raised
+ * before the edit transaction opens, so no partial profile is applied.
*/
open suspend operator fun invoke(
destNum: Int,
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
index 755db4ca93..1a7b4898cf 100644
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
@@ -48,7 +48,7 @@ sealed class RadioResponseResult {
data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult()
- data class Error(val message: UiText) : RadioResponseResult()
+ data class Error(val message: UiText, val routingError: Routing.Error? = null) : RadioResponseResult()
data object Success : RadioResponseResult()
}
@@ -80,9 +80,13 @@ open class ProcessRadioResponseUseCase {
private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? {
val parsed = Routing.ADAPTER.decode(data.payload)
+ val routingError = parsed.error_reason
return when {
- parsed.error_reason != Routing.Error.NONE ->
- RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0)))
+ routingError != null && routingError != Routing.Error.NONE ->
+ RadioResponseResult.Error(
+ message = UiText.Resource(getStringResFrom(routingError.value)),
+ routingError = routingError,
+ )
packet.from == destNum -> RadioResponseResult.Success
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
index 1fcfcb2e92..0b8e19d36f 100644
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
+++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
@@ -20,6 +20,7 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.log.expectedConditionLabel
import org.meshtastic.core.model.util.MalformedMeshtasticUrlException
import org.meshtastic.core.model.util.getChannelUrl
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeRadioController.SettingsOperation
@@ -122,6 +123,28 @@ class InstallProfileUseCaseTest {
assertTrue(radioController.editSettingsCalled)
}
+ @Test
+ fun `fixed position queue rejection aborts profile installation after closing the edit transaction`() = runTest {
+ val rejection = PacketQueueRejectedException("Fixed position")
+ radioController.onSetFixedPosition = { _, _ -> throw rejection }
+ val profile = DeviceProfile(fixed_position = org.meshtastic.proto.Position(latitude_i = 1, longitude_i = 1))
+
+ val failure =
+ assertFailsWith<PacketQueueRejectedException> {
+ useCase(
+ destNum = 1234,
+ profile = profile,
+ currentUser = User(),
+ currentLoraConfig = null,
+ isLocal = false,
+ )
+ }
+
+ assertEquals(rejection, failure)
+ assertEquals(listOf("begin", "commit"), radioController.adminOperations)
+ assertTrue(radioController.fixedPositions.isEmpty())
+ }
+
@Test
fun `invoke installs is_unmessagable but never auto-installs is_licensed`() = runTest {
val profile = DeviceProfile(is_unmessagable = true, is_licensed = true)
@@ -173,7 +196,9 @@ class InstallProfileUseCaseTest {
)
assertTrue(radioController.editSettingsCalled, "profile install transaction did not run")
- assertEquals((0..7).toList(), radioController.localChannels.map(Channel::index))
+ val channelWrites = radioController.channelWrites
+ assertEquals(List(8) { 4321 }, channelWrites.map(FakeRadioController.ChannelWrite::destination))
+ assertEquals((0..7).toList(), channelWrites.map { it.channel.index })
assertEquals(
listOf(
Channel.Role.PRIMARY,
@@ -185,14 +210,17 @@ class InstallProfileUseCaseTest {
Channel.Role.DISABLED,
Channel.Role.DISABLED,
),
- radioController.localChannels.map(Channel::role),
+ channelWrites.map { it.channel.role },
)
- assertEquals(listOf(primary, secondary), radioController.localChannels.take(2).map(Channel::settings))
+ assertEquals(listOf(primary, secondary), channelWrites.take(2).map { it.channel.settings })
assertEquals(listOf(primary, secondary), radioConfigRepository.currentChannelSet.settings)
assertEquals(urlLoraConfig, radioConfigRepository.currentChannelSet.lora_config)
- assertEquals(listOf(Config(lora = urlLoraConfig)), radioController.localConfigs)
assertEquals(
- radioController.localChannels.map { SettingsOperation.SetChannel(it) } +
+ listOf(FakeRadioController.ConfigWrite(destination = 4321, config = Config(lora = urlLoraConfig))),
+ radioController.configWrites,
+ )
+ assertEquals(
+ channelWrites.map { SettingsOperation.SetChannel(it.channel) } +
SettingsOperation.SetConfig(Config(lora = urlLoraConfig)),
radioController.settingsOperations,
)
@@ -212,7 +240,10 @@ class InstallProfileUseCaseTest {
assertTrue(radioController.editSettingsCalled)
assertTrue(radioController.localChannels.isEmpty())
- assertEquals(listOf(Config(lora = profileLoraConfig)), radioController.localConfigs)
+ assertEquals(
+ listOf(FakeRadioController.ConfigWrite(destination = 4321, config = Config(lora = profileLoraConfig))),
+ radioController.configWrites,
+ )
assertEquals(
listOf(FakeRadioConfigRepository.ChannelSetUpdate(settingsList = null, loraConfig = profileLoraConfig)),
radioConfigRepository.channelSetUpdates,
@@ -268,7 +299,14 @@ class InstallProfileUseCaseTest {
useCase(4321, profile, User(long_name = "Remote"), currentLoraConfig = null, isLocal = false)
- assertEquals(remotePrimary, radioController.localChannels.first().settings)
+ assertEquals(
+ FakeRadioController.ChannelWrite(
+ 4321,
+ Channel(index = 0, role = Channel.Role.PRIMARY, settings = remotePrimary),
+ ),
+ radioController.channelWrites.first(),
+ )
+ assertTrue(radioController.localChannels.isEmpty())
assertEquals(cachedChannelSet, radioConfigRepository.currentChannelSet)
}
@@ -301,7 +339,9 @@ class InstallProfileUseCaseTest {
fun `invoke rejects an empty channel set before opening the transaction`() = runTest {
val destinationPrimary =
Channel(role = Channel.Role.PRIMARY, index = 0, settings = ChannelSettings(name = "Node B Primary"))
- radioController.localChannels.add(destinationPrimary)
+ radioController.channelWrites.add(
+ FakeRadioController.ChannelWrite(destination = null, channel = destinationPrimary),
+ )
val profile =
DeviceProfile(
long_name = "Must Not Apply",
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
index 2ad33cad51..9946a99791 100644
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
+++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
@@ -25,6 +25,7 @@ import org.meshtastic.proto.Routing
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertIs
import kotlin.test.assertTrue
class ProcessRadioResponseUseCaseTest {
@@ -54,7 +55,22 @@ class ProcessRadioResponseUseCaseTest {
val result = useCase(packet, 123, setOf(42))
// Assert
- assertTrue(result is RadioResponseResult.Error)
+ val error = assertIs<RadioResponseResult.Error>(result)
+ assertEquals(Routing.Error.NO_ROUTE, error.routingError)
+ }
+
+ @Test
+ fun `routing response without error reason is not treated as an error`() {
+ val packet =
+ MeshPacket(
+ from = 123,
+ decoded =
+ Data(portnum = PortNum.ROUTING_APP, request_id = 42, payload = Routing().encode().toByteString()),
+ )
+
+ val result = useCase(packet, 123, setOf(42))
+
+ assertEquals(RadioResponseResult.Success, result)
}
@Test
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index c8bbdadb5b..64a5fce691 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -29,3 +29,53 @@ sealed interface ConnectionState {
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
data object DeviceSleep : ConnectionState
}
+
+/**
+ * One atomically published view of canonical connection state and its lifecycle evidence.
+ *
+ * Consumers that need to correlate a state with departure or handshake counters must observe this snapshot instead of
+ * reading [ConnectionState] and [ConnectionEpochs] flows independently.
+ */
+data class ConnectionLifecycle(
+ /**
+ * Monotonically increasing identity of this snapshot. Consumers capture it before a long operation and reject the
+ * result if the current value differs.
+ */
+ val version: Long = 0,
+ val state: ConnectionState = ConnectionState.Disconnected,
+ val epochs: ConnectionEpochs = ConnectionEpochs(),
+)
+
+/**
+ * Monotonic counters for connection lifecycle events that cannot be inferred reliably from transient [ConnectionState]
+ * values. A fast disconnect/reconnect may be observed only as the final state, while these counters retain both
+ * lifecycle boundaries.
+ *
+ * @property departures Number of transitions away from [ConnectionState.Connected].
+ * @property completedHandshakes Number of transitions into [ConnectionState.Connected].
+ * @property handshakesAtLastDeparture Completed-handshake count captured at the most recent departure. This preserves
+ * event ordering when a fast departure/reconnect pair is conflated into one observed state-flow value.
+ * @property lastDepartureState State entered by the most recent departure. Consumers that react after a fast reconnect
+ * can use this event evidence instead of rereading the already-advanced live connection state.
+ */
+data class ConnectionEpochs(
+ val departures: Long = 0,
+ val completedHandshakes: Long = 0,
+ val handshakesAtLastDeparture: Long = 0,
+ val lastDepartureState: ConnectionState? = null,
+) {
+ /** Returns the counters after applying one canonical connection-state transition. */
+ fun advance(previous: ConnectionState, current: ConnectionState): ConnectionEpochs = when {
+ previous is ConnectionState.Connected && current !is ConnectionState.Connected ->
+ copy(
+ departures = departures + 1,
+ handshakesAtLastDeparture = completedHandshakes,
+ lastDepartureState = current,
+ )
+
+ previous !is ConnectionState.Connected && current is ConnectionState.Connected ->
+ copy(completedHandshakes = completedHandshakes + 1)
+
+ else -> this
+ }
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt
index 67036c6001..c6a0566692 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshActivity.kt
@@ -18,7 +18,7 @@ package org.meshtastic.core.model
/** Represents activity on the mesh network. */
sealed class MeshActivity {
- /** Data is being sent to the radio. */
+ /** The active transport accepted a local outbound handoff; this does not confirm radio or mesh delivery. */
data object Send : MeshActivity()
/** Data is being received from the radio. */
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt
index 87a2b9ab3a..58c97304e5 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Position.kt
@@ -65,6 +65,9 @@ data class Position(
/** @return bearing to the other position in degrees */
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
+ /** Returns whether this position represents the protocol sentinel for removing a fixed position. */
+ fun isFixedPositionRemoval(): Boolean = latitude == 0.0 && longitude == 0.0 && altitude == 0
+
@Suppress("MagicNumber")
fun isValid(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
diff --git a/core/network/src/androidHostTest/kotlin/org/meshtastic/core/network/radio/SerialRadioTransportTest.kt b/core/network/src/androidHostTest/kotlin/org/meshtastic/core/network/radio/SerialRadioTransportTest.kt
new file mode 100644
index 0000000000..8ba7a0b947
--- /dev/null
+++ b/core/network/src/androidHostTest/kotlin/org/meshtastic/core/network/radio/SerialRadioTransportTest.kt
@@ -0,0 +1,527 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import com.hoho.android.usbserial.driver.UsbSerialDriver
+import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode.Companion.exactly
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.network.repository.SerialConnection
+import org.meshtastic.core.network.repository.SerialConnectionListener
+import org.meshtastic.core.repository.RadioTransportCallback
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+class SerialRadioTransportTest {
+
+ private val callback: RadioTransportCallback = mock(MockMode.autofill)
+ private val serialDriver: UsbSerialDriver = mock(MockMode.autofill)
+ private val serialConnection: SerialConnection = mock(MockMode.autofill)
+ private lateinit var serialConnectionListener: SerialConnectionListener
+ private var requestedDriver: UsbSerialDriver? = null
+
+ private fun createTransport(address: String, scope: CoroutineScope): SerialRadioTransport {
+ requestedDriver = null
+ return SerialRadioTransport(
+ callback = callback,
+ scope = scope,
+ serialDevices = MutableStateFlow(mapOf(address to serialDriver)),
+ createSerialConnection = { driver, listener ->
+ requestedDriver = driver
+ serialConnectionListener = listener
+ serialConnection
+ },
+ address = address,
+ )
+ }
+
+ private fun createRecordingTransport(
+ address: String,
+ connections: MutableList<RecordingSerialConnection>,
+ scope: CoroutineScope,
+ devices: Map<String, UsbSerialDriver> = mapOf(address to serialDriver),
+ ): SerialRadioTransport {
+ requestedDriver = null
+ return SerialRadioTransport(
+ callback = callback,
+ scope = scope,
+ serialDevices = MutableStateFlow(devices),
+ createSerialConnection = { driver, listener ->
+ requestedDriver = driver
+ RecordingSerialConnection(listener).also(connections::add)
+ },
+ address = address,
+ )
+ }
+
+ private fun assertRequestedDriver(expected: UsbSerialDriver = serialDriver) {
+ assertSame(expected, requestedDriver, "serial connection factory must receive the selected driver")
+ }
+
+ @Test
+ fun `failed connection is not left admitted`() = runTest {
+ val address = "serial-device"
+ every { serialConnection.connect() } throws IllegalStateException("connect failed")
+ val transport = createTransport(address, backgroundScope)
+
+ transport.start()
+ testScheduler.runCurrent()
+ assertRequestedDriver()
+
+ verify(exactly(1)) { serialConnection.close(waitForStopped = false) }
+ verify(exactly(1)) {
+ callback.onDisconnect(isPermanent = false, errorMessage = "connect failed", reason = null)
+ }
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1)))
+ transport.close()
+ }
+
+ @Test
+ fun `send is rejected promptly while connection is still opening`() = runBlocking<Unit> {
+ val address = "serial-device"
+ val connectEntered = CountDownLatch(1)
+ val releaseConnect = CountDownLatch(1)
+ every { serialConnection.connect() } calls
+ {
+ connectEntered.countDown()
+ check(releaseConnect.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ serialConnectionListener.onConnected()
+ }
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val transport = createTransport(address, transportScope)
+ val executor = Executors.newFixedThreadPool(2)
+
+ try {
+ val startFuture = executor.submit { transport.start() }
+ assertTrue(
+ connectEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS),
+ "connect should enter the blocking open",
+ )
+
+ val sendFuture = executor.submit<Boolean> { transport.handleSendToRadio(byteArrayOf(1)) }
+ assertFalse(
+ sendFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS),
+ "an opening transport must reject promptly",
+ )
+
+ releaseConnect.countDown()
+ startFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ assertRequestedDriver()
+ } finally {
+ releaseConnect.countDown()
+ transport.close()
+ transportScope.cancel()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `close orders against connection creation before publication`() = runBlocking<Unit> {
+ val address = "serial-device"
+ val creationEntered = CountDownLatch(1)
+ val releaseCreation = CountDownLatch(1)
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ var factoryDriver: UsbSerialDriver? = null
+ val transport =
+ SerialRadioTransport(
+ callback = callback,
+ scope = transportScope,
+ serialDevices = MutableStateFlow(mapOf(address to serialDriver)),
+ createSerialConnection = { driver, listener ->
+ factoryDriver = driver
+ serialConnectionListener = listener
+ creationEntered.countDown()
+ check(releaseCreation.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ serialConnection
+ },
+ address = address,
+ )
+ val executor = Executors.newFixedThreadPool(2)
+
+ try {
+ val startFuture = executor.submit { transport.start() }
+ assertTrue(creationEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+
+ val closeFuture = executor.submit { runBlocking { transport.close() } }
+ assertFailsWith<TimeoutException> { closeFuture.get(NOT_YET_WINDOW_MS, TimeUnit.MILLISECONDS) }
+
+ releaseCreation.countDown()
+ startFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ closeFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+
+ assertSame(serialDriver, factoryDriver, "serial connection factory must receive the selected driver")
+ verify(exactly(0)) { serialConnection.connect() }
+ verify(exactly(1)) { serialConnection.close(waitForStopped = false) }
+ } finally {
+ releaseCreation.countDown()
+ transport.close()
+ transportScope.cancel()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `close waits for wake IO then revokes without publishing connected`() = runBlocking<Unit> {
+ val address = "serial-device"
+ val wakeWriteEntered = CountDownLatch(1)
+ val releaseWakeWrite = CountDownLatch(1)
+ val closeEntered = CountDownLatch(1)
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ every { serialConnection.sendBytes(any()) } calls
+ {
+ wakeWriteEntered.countDown()
+ check(releaseWakeWrite.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ }
+ every { serialConnection.close(waitForStopped = true) } calls { closeEntered.countDown() }
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val transport = createTransport(address, transportScope)
+ val executor = Executors.newFixedThreadPool(2)
+
+ try {
+ val startFuture = executor.submit { transport.start() }
+ assertTrue(
+ wakeWriteEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS),
+ "connected callback should enter wake I/O",
+ )
+ assertFalse(
+ transport.handleSendToRadio(byteArrayOf(1)),
+ "ordinary writes must remain closed until wake I/O completes",
+ )
+
+ val closeFuture = executor.submit { runBlocking { transport.close() } }
+ assertFalse(
+ closeEntered.await(NOT_YET_WINDOW_MS, TimeUnit.MILLISECONDS),
+ "close must not close the connection while admitted wake I/O is still running",
+ )
+
+ releaseWakeWrite.countDown()
+ startFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ assertRequestedDriver()
+ closeFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ assertTrue(closeEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ verify(exactly(0)) { callback.onConnect() }
+ } finally {
+ releaseWakeWrite.countDown()
+ transport.close()
+ transportScope.cancel()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `unexpected wake failure closes the connection without publishing connected`() = runTest {
+ val address = "serial-device"
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ every { serialConnection.sendBytes(any()) } throws UnsupportedOperationException("wake failed")
+ val transport = createTransport(address, backgroundScope)
+
+ transport.start()
+ testScheduler.runCurrent()
+ assertRequestedDriver()
+
+ verify(exactly(0)) { callback.onConnect() }
+ verify(exactly(1)) { callback.onDisconnect(isPermanent = false, errorMessage = null, reason = null) }
+ verify(exactly(1)) { serialConnection.close(waitForStopped = false) }
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1)))
+ transport.close()
+ }
+
+ @Test
+ fun `explicit close suppresses a disconnect notification deferred by admitted IO`() = runTest {
+ val address = "serial-device"
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ val transport = createTransport(address, this)
+
+ transport.start()
+ assertRequestedDriver()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1)))
+ serialConnectionListener.onDisconnected(null)
+ transport.close()
+ testScheduler.runCurrent()
+
+ verify(exactly(0)) { callback.onDisconnect(isPermanent = false, errorMessage = null, reason = null) }
+ verify(exactly(1)) { serialConnection.close(waitForStopped = false) }
+ }
+
+ @Test
+ fun `close drains an admitted queued write before closing its connection`() = runBlocking<Unit> {
+ val address = "serial-device"
+ val writeEntered = CountDownLatch(1)
+ val releaseWrite = CountDownLatch(1)
+ val closeEntered = CountDownLatch(1)
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val transport = createTransport(address, transportScope)
+ val executor = Executors.newSingleThreadExecutor()
+
+ transport.start()
+ assertRequestedDriver()
+ every { serialConnection.sendBytes(any()) } calls
+ {
+ writeEntered.countDown()
+ check(releaseWrite.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ }
+ every { serialConnection.close(waitForStopped = true) } calls { closeEntered.countDown() }
+
+ try {
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+ assertTrue(
+ writeEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS),
+ "the admitted write should reach the connection",
+ )
+
+ val closeFuture = executor.submit { runBlocking { transport.close() } }
+ assertFalse(
+ closeEntered.await(NOT_YET_WINDOW_MS, TimeUnit.MILLISECONDS),
+ "close must wait for the admitted write handoff",
+ )
+
+ releaseWrite.countDown()
+ closeFuture.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ assertTrue(closeEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ } finally {
+ releaseWrite.countDown()
+ transport.close()
+ transportScope.cancel()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `stale terminal callbacks do not tear down a replacement connection`() = runTest {
+ val address = "serial-device"
+ val connections = mutableListOf<RecordingSerialConnection>()
+ val transport = createRecordingTransport(address, connections, this)
+
+ transport.start()
+ assertRequestedDriver()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1)))
+ connections[0].listener.onDisconnected(null)
+ assertFalse(transport.handleSendToRadio(byteArrayOf(2)))
+ testScheduler.runCurrent()
+ verify(exactly(1)) { callback.onDisconnect(isPermanent = false, errorMessage = null, reason = null) }
+ assertEquals(listOf(false), connections[0].closeRequests)
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(3)))
+ connections[0].listener.onDisconnected(IllegalStateException("late disconnect"))
+ assertTrue(transport.handleSendToRadio(byteArrayOf(4)))
+ connections[0].listener.onMissingPermission()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(5)))
+
+ testScheduler.runCurrent()
+ // Mokkery verifies only calls not consumed by the earlier assertion, so stale callbacks add zero new events.
+ verify(exactly(0)) { callback.onDisconnect(isPermanent = false, errorMessage = null, reason = null) }
+ assertEquals(listOf(false), connections[0].closeRequests)
+ assertTrue(connections[1].closeRequests.isEmpty(), "stale callbacks must not close the replacement")
+ transport.close()
+ assertEquals(listOf(true), connections[1].closeRequests)
+ }
+
+ @Test
+ fun `start during connection cleanup opens a replacement after cleanup completes`() = runTest {
+ val address = "serial-device"
+ val connections = mutableListOf<RecordingSerialConnection>()
+ val transport = createRecordingTransport(address, connections, this)
+
+ transport.start()
+ assertRequestedDriver()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1)))
+ connections.single().listener.onDisconnected(null)
+
+ transport.start()
+ assertEquals(1, connections.size, "replacement must wait for physical cleanup")
+ testScheduler.runCurrent()
+
+ assertEquals(2, connections.size)
+ assertTrue(transport.handleSendToRadio(byteArrayOf(2)))
+ transport.close()
+ assertEquals(listOf(true), connections[1].closeRequests)
+ }
+
+ @Test
+ fun `repeated start cannot replace an active connection generation`() = runTest {
+ val address = "serial-device"
+ val connections = mutableListOf<RecordingSerialConnection>()
+ val transport = createRecordingTransport(address, connections, this)
+
+ transport.start()
+ assertRequestedDriver()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1)))
+ transport.start()
+
+ assertEquals(1, connections.size, "repeated start must not open a throwaway USB generation")
+ assertEquals(emptyList(), connections[0].closeRequests)
+ assertTrue(transport.handleSendToRadio(byteArrayOf(2)))
+ transport.close()
+ assertEquals(listOf(true), connections[0].closeRequests)
+ }
+
+ @Test
+ fun `concurrent close callers await the same physical teardown`() = runBlocking<Unit> {
+ val address = "serial-device"
+ val closeEntered = CountDownLatch(1)
+ val releaseClose = CountDownLatch(1)
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ every { serialConnection.close(waitForStopped = true) } calls
+ {
+ closeEntered.countDown()
+ check(releaseClose.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ }
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val transport = createTransport(address, transportScope)
+ val executor = Executors.newFixedThreadPool(2)
+
+ try {
+ transport.start()
+ assertRequestedDriver()
+ val first = executor.submit { runBlocking { transport.close() } }
+ assertTrue(closeEntered.await(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS))
+ val second = executor.submit { runBlocking { transport.close() } }
+ assertFailsWith<TimeoutException> { second.get(NOT_YET_WINDOW_MS, TimeUnit.MILLISECONDS) }
+
+ releaseClose.countDown()
+ first.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ second.get(COMPLETION_WINDOW_SECONDS, TimeUnit.SECONDS)
+ verify(exactly(1)) { serialConnection.close(waitForStopped = true) }
+ } finally {
+ releaseClose.countDown()
+ transportScope.cancel()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `legacy selected address resolves the sole USB serial device`() = runTest {
+ val selectedAddress = "legacy-path-address"
+ val soleDevice = mock<UsbSerialDriver>()
+ val connections = mutableListOf<RecordingSerialConnection>()
+ val transport =
+ createRecordingTransport(
+ address = selectedAddress,
+ connections = connections,
+ scope = backgroundScope,
+ devices = mapOf("stable-usb-key" to soleDevice),
+ )
+
+ transport.start()
+ testScheduler.runCurrent()
+ assertRequestedDriver(soleDevice)
+
+ assertEquals(
+ 1,
+ connections.size,
+ "a legacy address may self-heal when exactly one USB serial device is present",
+ )
+ transport.close()
+ }
+
+ @Test
+ fun `ambiguous missing selected device never falls back to another USB serial device`() = runTest {
+ val selectedAddress = "selected-device"
+ val connections = mutableListOf<RecordingSerialConnection>()
+ val transport =
+ createRecordingTransport(
+ address = selectedAddress,
+ connections = connections,
+ scope = backgroundScope,
+ devices = mapOf("other-a" to mock<UsbSerialDriver>(), "other-b" to mock<UsbSerialDriver>()),
+ )
+
+ transport.start()
+ testScheduler.runCurrent()
+
+ assertTrue(connections.isEmpty(), "an ambiguous selected-address miss must not open an unrelated serial device")
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1)))
+ transport.close()
+ }
+
+ @Test
+ fun `start after close cannot reopen a serial transport`() = runTest {
+ val address = "serial-device"
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ val transport = createTransport(address, this)
+
+ transport.start()
+ assertRequestedDriver()
+ transport.close()
+ transport.start()
+
+ verify(exactly(1)) { serialConnection.connect() }
+ verify(exactly(1)) { serialConnection.close(waitForStopped = true) }
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1)))
+ }
+
+ @Test
+ fun `send is rejected before connection and after close`() = runTest {
+ val address = "serial-device"
+ val transport = createTransport(address, this)
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1)))
+
+ every { serialConnection.connect() } calls { serialConnectionListener.onConnected() }
+ transport.start()
+ assertRequestedDriver()
+ assertTrue(transport.handleSendToRadio(byteArrayOf(2)), "an established connection must accept the handoff")
+ transport.close()
+
+ verify(exactly(1)) { serialConnection.close(waitForStopped = true) }
+ assertFalse(transport.handleSendToRadio(byteArrayOf(3)))
+ }
+
+ private class RecordingSerialConnection(val listener: SerialConnectionListener) : SerialConnection {
+ val closeRequests = mutableListOf<Boolean>()
+
+ override fun connect() = listener.onConnected()
+
+ override fun sendBytes(bytes: ByteArray) = Unit
+
+ override fun close(waitForStopped: Boolean) {
+ closeRequests += waitForStopped
+ }
+
+ override fun close() = close(waitForStopped = false)
+ }
+
+ private companion object {
+ // Negative wall-clock assertions get a generous scheduler margin without slowing successful paths.
+ const val NOT_YET_WINDOW_MS = 500L
+
+ // Positive waits tolerate contended CI scheduling; exceeding this bound indicates a real hang.
+ const val COMPLETION_WINDOW_SECONDS = 10L
+ }
+}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
index 7ee222e957..0996a04726 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
@@ -97,7 +97,9 @@ class AndroidRadioTransportFactory(
InterfaceId.SERIAL -> {
val deviceMap = usbRepository.serialDevices.value
- val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull()
+ // Older installs may still hold the former path-based USB address. When exactly one serial device is
+ // present, retain the historical self-healing fallback instead of rejecting an otherwise usable radio.
+ val driver = resolveSerialDevice(deviceMap, rest)
driver != null && usbManager.hasPermission(driver.device)
}
@@ -126,7 +128,8 @@ class AndroidRadioTransportFactory(
SerialRadioTransport(
callback = service,
scope = service.serviceScope,
- usbRepository = usbRepository,
+ serialDevices = usbRepository.serialDevices,
+ createSerialConnection = usbRepository::createSerialConnection,
address = rest,
)
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
index b983165da4..a36da7db08 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
@@ -17,52 +17,112 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import com.hoho.android.usbserial.driver.UsbSerialDriver
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.network.repository.SerialConnection
import org.meshtastic.core.network.repository.SerialConnectionListener
-import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.network.transport.HeartbeatSender
+import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioTransportCallback
import org.meshtastic.core.repository.TransportDisconnectReason
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
+import kotlin.time.Duration.Companion.seconds
+
+// The outer teardown can await one nested connection-gate close (15 s drain + 15 s teardown) and its connect wait.
+private val SERIAL_TRANSPORT_TEARDOWN_TIMEOUT =
+ TransportLifecycleGate.OPERATION_DRAIN_TIMEOUT + TransportLifecycleGate.TEARDOWN_TIMEOUT + 15.seconds
+
+/** Resolves a selected serial address, retaining the legacy single-device fallback used by older installs. */
+internal fun resolveSerialDevice(devices: Map<String, UsbSerialDriver>, address: String): UsbSerialDriver? =
+ devices[address] ?: devices.values.singleOrNull()
/** An Android USB/serial [RadioTransport] implementation. */
+@Suppress("TooManyFunctions")
class SerialRadioTransport(
callback: RadioTransportCallback,
scope: CoroutineScope,
- private val usbRepository: UsbRepository,
+ private val serialDevices: StateFlow<Map<String, UsbSerialDriver>>,
+ private val createSerialConnection: (UsbSerialDriver, SerialConnectionListener) -> SerialConnection,
private val address: String,
) : StreamTransport(callback, scope) {
- private var connRef = AtomicReference<SerialConnection?>()
+ private val connRef = AtomicReference<SerialConnection?>()
+ private val connectionAdmissionLock = Any()
- private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]")
+ private class ConnectionToken {
+ var connection: SerialConnection? = null
+ val lifecycle = TransportLifecycleGate("Android serial connection")
+ val connectCompletion = CompletableDeferred<Unit>()
+ }
- /**
- * Set while an explicit [close] is tearing down the connection so the reader thread's
- * [SerialConnectionListener.onDisconnected] callback (invoked synchronously by `port.close()` through the reader's
- * `onRunError`) does NOT forward a transient [onDeviceDisconnect] up to the orchestrator. The service-layer caller
- * of `close()` (SharedRadioInterfaceService's stopTransportLocked) owns post-close notification and would otherwise
- * observe a double emission: a transient DeviceSleep from this callback chain followed by its own intended
- * disconnect emission.
- */
- private val explicitCloseInProgress = AtomicBoolean(false)
+ private data class ConnectionOperation(
+ val token: ConnectionToken,
+ val connection: SerialConnection,
+ val lease: TransportLifecycleGate.OperationLease,
+ )
- override fun start() {
- connect()
- }
+ private data class ClaimedConnection(
+ val token: ConnectionToken,
+ val connection: SerialConnection,
+ val cleanupCompletion: CompletableDeferred<Unit>,
+ )
+
+ private data class ConnectionStats(
+ val connectStartedAt: Long,
+ var connectedAt: Long = 0L,
+ var packetsReceived: Int = 0,
+ var bytesReceived: Long = 0L,
+ )
+
+ private var activeConnectionToken: ConnectionToken? = null
+
+ /** Guarded by [connectionAdmissionLock]; replacement waits until old physical cleanup and notification finish. */
+ private var connectionCleanupInProgress = false
+ private var connectionCleanupCompletion: CompletableDeferred<Unit>? = null
+ private var connectAfterCleanupScheduled = false
+ private val connectionReady = AtomicBoolean(false)
+ private val lifecycle =
+ TransportLifecycleGate("Android serial", teardownTimeout = SERIAL_TRANSPORT_TEARDOWN_TIMEOUT)
+
+ // Detached from the service scope so a claimed generation always publishes cleanup completion.
+ private val cleanupScope = CoroutineScope(SupervisorJob() + scope.coroutineContext.minusKey(Job))
+
+ private val heartbeatSender = HeartbeatSender(sendToRadio = { handleSendToRadio(it) }, logTag = "Serial[$address]")
+
+ override fun start() = connect()
override suspend fun close() {
- Logger.d { "[$address] Closing serial transport" }
- explicitCloseInProgress.set(true)
+ var completed = false
try {
- closeConnection(waitForStopped = true)
- super.close()
+ completed =
+ lifecycle.close(
+ beforeDrain = {
+ // Closing admission first suppresses a deferred disconnect while stopping this worker releases
+ // its transport and connection leases.
+ super.close()
+ },
+ teardown = {
+ Logger.d { "[$address] Closing serial transport" }
+ closeConnectionAndAwaitConnect(waitForStopped = true)
+ },
+ )
} finally {
- explicitCloseInProgress.set(false)
+ cleanupScope.cancel()
}
+ if (!completed) Logger.w { "[$address] Serial teardown did not complete within its lifecycle bounds" }
}
override fun onDeviceDisconnect(
@@ -71,123 +131,394 @@ class SerialRadioTransport(
errorMessage: String?,
reason: TransportDisconnectReason?,
) {
- if (closeConnection(waitForStopped)) {
- super.onDeviceDisconnect(waitForStopped, isPermanent, errorMessage, reason)
+ disconnectConnection(
+ connectionToken = null,
+ waitForStopped = waitForStopped,
+ isPermanent = isPermanent,
+ errorMessage = errorMessage,
+ reason = reason,
+ )
+ }
+
+ private fun claimConnection(connectionToken: ConnectionToken? = null): ClaimedConnection? =
+ synchronized(connectionAdmissionLock) {
+ val token = activeConnectionToken ?: return@synchronized null
+ val connection = connRef.get() ?: return@synchronized null
+ if (connectionToken != null && !ownsConnectionLocked(connectionToken, connection)) return@synchronized null
+ connRef.set(null)
+ connectionReady.set(false)
+ activeConnectionToken = null
+ token.connection = null
+ connectionCleanupInProgress = true
+ val cleanupCompletion = CompletableDeferred<Unit>().also { connectionCleanupCompletion = it }
+ ClaimedConnection(token, connection, cleanupCompletion)
+ }
+
+ /** Caller must hold [connectionAdmissionLock]. */
+ private fun ownsConnectionLocked(connectionToken: ConnectionToken, connection: SerialConnection): Boolean =
+ activeConnectionToken === connectionToken &&
+ connectionToken.connection === connection &&
+ connRef.get() === connection
+
+ private suspend fun closeConnectionAndAwaitConnect(waitForStopped: Boolean): Boolean {
+ val claimed = claimConnection()
+ if (claimed == null) {
+ awaitPendingConnectionCleanup()
+ return false
+ }
+ try {
+ closeClaimedConnection(claimed, waitForStopped)
+ } finally {
+ finishConnectionCleanup(claimed.cleanupCompletion)
+ }
+ return true
+ }
+
+ private suspend fun awaitPendingConnectionCleanup() {
+ synchronized(connectionAdmissionLock) { connectionCleanupCompletion }?.await()
+ }
+
+ private suspend fun closeClaimedConnection(claimed: ClaimedConnection, waitForStopped: Boolean) {
+ val completed =
+ claimed.token.lifecycle.close {
+ claimed.connection.close(waitForStopped)
+ claimed.token.connectCompletion.awaitForClose("connection attempt")
+ }
+ if (!completed) {
+ Logger.w { "[$address] Serial connection teardown did not complete within its lifecycle bounds" }
}
}
- private fun closeConnection(waitForStopped: Boolean): Boolean {
- val connection = connRef.getAndSet(null) ?: return false
- connection.close(waitForStopped)
+ private suspend fun Deferred<Unit>.awaitForClose(phase: String) {
+ val timeout = TransportLifecycleGate.OPERATION_DRAIN_TIMEOUT
+ val completed =
+ withTimeoutOrNull(timeout) {
+ await()
+ true
+ } == true
+ if (!completed) Logger.w { "[$address] Serial close timed out after $timeout while waiting for $phase" }
+ }
+
+ private fun disconnectConnection(
+ connectionToken: ConnectionToken?,
+ waitForStopped: Boolean,
+ isPermanent: Boolean,
+ errorMessage: String? = null,
+ reason: TransportDisconnectReason? = null,
+ ): Boolean {
+ val claimed = claimConnection(connectionToken) ?: return false
+ launchConnectionCleanup {
+ try {
+ closeClaimedConnection(claimed, waitForStopped)
+ } finally {
+ try {
+ notifyDeviceDisconnect(waitForStopped, isPermanent, errorMessage, reason)
+ } finally {
+ finishConnectionCleanup(claimed.cleanupCompletion)
+ }
+ }
+ }
return true
}
+ private fun launchConnectionCleanup(block: suspend () -> Unit) {
+ cleanupScope.handledLaunch(start = CoroutineStart.UNDISPATCHED) { withContext(NonCancellable) { block() } }
+ }
+
+ private fun notifyDeviceDisconnect(
+ waitForStopped: Boolean,
+ isPermanent: Boolean,
+ errorMessage: String?,
+ reason: TransportDisconnectReason?,
+ ) {
+ // Explicit close owns the terminal service-layer notification. Admission at emission time linearizes this
+ // callback with close even when physical cleanup was deferred behind an older operation.
+ lifecycle.runIfOpen { super.onDeviceDisconnect(waitForStopped, isPermanent, errorMessage, reason) }
+ }
+
override fun connect() {
- val deviceMap = usbRepository.serialDevices.value
- val device = deviceMap[address] ?: deviceMap.values.firstOrNull()
- if (device == null) {
- Logger.e { "[$address] Serial device not found at address" }
- } else {
- val connectStart = nowMillis
- Logger.i { "[$address] Opening serial device: $device" }
-
- var packetsReceived = 0
- var bytesReceived = 0L
- var connectionStartTime = 0L
-
- val onConnect: () -> Unit = {
- connectionStartTime = nowMillis
- val connectionTime = connectionStartTime - connectStart
- Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
- super.connect()
+ when {
+ lifecycle.isClosed -> Logger.d { "[$address] Ignoring start after serial transport close" }
+
+ deferConnectUntilCleanup() -> Unit
+
+ hasActiveConnection() -> Logger.d { "[$address] Ignoring start while serial generation is active" }
+
+ else -> {
+ val devices = serialDevices.value
+ // Keep legacy path-based selections usable when a single serial device can be resolved unambiguously.
+ // Generation ownership still binds all callbacks to this transport instance after the device is chosen.
+ val device = resolveSerialDevice(devices, address)
+ if (device == null) {
+ Logger.e { "[$address] Serial device not found at selected address" }
+ } else {
+ openConnection(device)
+ }
}
+ }
+ }
- usbRepository
- .createSerialConnection(
- device,
- object : SerialConnectionListener {
- override fun onMissingPermission() {
- Logger.e {
- "[$address] Serial connection failed - missing USB permissions for device: $device"
- }
- // Permission denial is terminal for this connection attempt: stop the reconnect loop
- // and let the service/UI layer choose the user-facing copy for the structured reason.
- onDeviceDisconnect(
- waitForStopped = false,
- isPermanent = true,
- errorMessage = null,
- reason = TransportDisconnectReason.UsbPermissionDenied,
- )
- }
+ private fun hasActiveConnection(): Boolean =
+ synchronized(connectionAdmissionLock) { activeConnectionToken != null || connRef.get() != null }
- override fun onConnected() {
- onConnect.invoke()
- }
+ private fun deferConnectUntilCleanup(): Boolean {
+ var cleanup: Deferred<Unit>? = null
+ val deferred =
+ synchronized(connectionAdmissionLock) {
+ if (!connectionCleanupInProgress) {
+ false
+ } else {
+ if (!connectAfterCleanupScheduled) {
+ connectAfterCleanupScheduled = true
+ cleanup = checkNotNull(connectionCleanupCompletion)
+ }
+ true
+ }
+ }
+ cleanup?.let { completion ->
+ scope.handledLaunch {
+ try {
+ completion.await()
+ } finally {
+ synchronized(connectionAdmissionLock) { connectAfterCleanupScheduled = false }
+ }
+ connect()
+ }
+ }
+ return deferred
+ }
- override fun onDataReceived(bytes: ByteArray) {
- packetsReceived++
- bytesReceived += bytes.size
- Logger.d {
- "[$address] Serial received packet #$packetsReceived - " +
- "${bytes.size} byte(s) (Total RX: $bytesReceived bytes)"
- }
- bytes.forEach(::readChar)
- }
+ private fun openConnection(device: UsbSerialDriver) {
+ val startupLease = lifecycle.tryAcquire()
+ if (startupLease == null) {
+ Logger.d { "[$address] Ignoring serial open after transport close" }
+ return
+ }
+ try {
+ openAdmittedConnection(device)
+ } finally {
+ startupLease.release()
+ }
+ }
- override fun onDisconnected(thrown: Exception?) {
- // Skip the disconnect callback when the reader-thread termination is the
- // direct result of an explicit close() — the caller owns the post-close
- // notification, and the expected close must not emit warning-log noise.
- // USB unplug / cable error is the only path that should log + forward a
- // transient disconnect here.
- if (explicitCloseInProgress.get()) {
- return
- }
+ @Suppress("TooGenericExceptionCaught")
+ private fun openAdmittedConnection(device: UsbSerialDriver) {
+ val stats = ConnectionStats(connectStartedAt = nowMillis)
+ Logger.i { "[$address] Opening serial device: $device" }
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- thrown?.let { e ->
- // USB errors are common when unplugging; log as warning to avoid Crashlytics noise
- Logger.w(e) { "[$address] Serial error after ${uptime}ms: ${e.message}" }
- }
- Logger.w {
- "[$address] Serial device disconnected - " +
- "Device: $device, " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes)"
- }
- // USB unplug / cable error is transient — the transport will reconnect when
- // the device is replugged or the OS re-enumerates the port. Only close()
- // (user disconnects) and missing-permission (see onMissingPermission) signal
- // a permanent disconnect; cable unplug / I/O errors are transient.
- onDeviceDisconnect(waitForStopped = false, isPermanent = false)
- }
- },
+ val connectionToken = ConnectionToken()
+ val connection = createSerialConnection(device, createConnectionListener(device, connectionToken, stats))
+ if (!publishConnection(connectionToken, connection)) {
+ Logger.d { "[$address] Serial generation is active or still tearing down; closing unused connection" }
+ connection.close(waitForStopped = false)
+ return
+ }
+
+ try {
+ connection.connect()
+ } catch (e: CancellationException) {
+ disconnectConnection(connectionToken, waitForStopped = false, isPermanent = false, errorMessage = e.message)
+ throw e
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Serial connect failed" }
+ disconnectConnection(
+ connectionToken = connectionToken,
+ waitForStopped = false,
+ isPermanent = false,
+ errorMessage = e.message,
+ )
+ } finally {
+ connectionToken.connectCompletion.complete(Unit)
+ }
+ }
+
+ private fun createConnectionListener(
+ device: UsbSerialDriver,
+ connectionToken: ConnectionToken,
+ stats: ConnectionStats,
+ ): SerialConnectionListener = object : SerialConnectionListener {
+ override fun onMissingPermission() {
+ if (
+ disconnectConnection(
+ connectionToken = connectionToken,
+ waitForStopped = false,
+ isPermanent = true,
+ reason = TransportDisconnectReason.UsbPermissionDenied,
)
- .also { conn ->
- connRef.set(conn)
- conn.connect()
+ ) {
+ Logger.w { "Serial connection unavailable: USB permission denied" }
+ }
+ }
+
+ override fun onConnected() = handleConnected(connectionToken, stats)
+
+ override fun onDataReceived(bytes: ByteArray) = handleDataReceived(connectionToken, stats, bytes)
+
+ override fun onDisconnected(thrown: Exception?) = handleDisconnected(connectionToken, stats, device, thrown)
+ }
+
+ private fun handleConnected(connectionToken: ConnectionToken, stats: ConnectionStats) {
+ val operation = admitConnectionOperation(connectionToken, requireReady = false) ?: return
+ var wakeFailure: Exception? = null
+ try {
+ stats.connectedAt = nowMillis
+ val connectionTime = stats.connectedAt - stats.connectStartedAt
+ Logger.i { "[$address] Serial device connected in ${connectionTime}ms" }
+ wakeFailure = sendWakeBytes(operation.connection)
+ if (wakeFailure == null) {
+ val readyToPublish =
+ synchronized(connectionAdmissionLock) {
+ ownsConnectionLocked(connectionToken, operation.connection).also { owns ->
+ if (owns) connectionReady.set(true)
+ }
+ }
+ if (readyToPublish) {
+ lifecycle.runIfOpen {
+ val stillOwned =
+ synchronized(connectionAdmissionLock) {
+ ownsConnectionLocked(connectionToken, operation.connection) && connectionReady.get()
+ }
+ if (stillOwned) callback.onConnect()
+ }
}
+ }
+ } finally {
+ operation.lease.release()
}
+ wakeFailure?.let { handleWakeFailure(connectionToken, it) }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private fun sendWakeBytes(connection: SerialConnection): Exception? = try {
+ // SerialConnection.sendBytes is asynchronous. This lease guarantees handoff to this exact connection
+ // generation's write queue; it does not claim physical wire delivery.
+ connection.sendBytes(StreamFrameCodec.WAKE_BYTES)
+ null
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ e
+ }
+
+ private fun handleWakeFailure(connectionToken: ConnectionToken, failure: Exception) {
+ Logger.w(failure) { "[$address] Serial wake failed; ending connection generation" }
+ disconnectConnection(connectionToken, waitForStopped = false, isPermanent = false)
}
+ private fun handleDataReceived(connectionToken: ConnectionToken, stats: ConnectionStats, bytes: ByteArray) {
+ val operation = admitConnectionOperation(connectionToken, requireReady = false) ?: return
+ try {
+ stats.packetsReceived++
+ stats.bytesReceived += bytes.size
+ Logger.d {
+ "[$address] Serial received packet #${stats.packetsReceived} - " +
+ "${bytes.size} byte(s) (Total RX: ${stats.bytesReceived} bytes)"
+ }
+ bytes.forEach(::readChar)
+ } finally {
+ operation.lease.release()
+ }
+ }
+
+ private fun handleDisconnected(
+ connectionToken: ConnectionToken,
+ stats: ConnectionStats,
+ device: UsbSerialDriver,
+ thrown: Exception?,
+ ) {
+ if (lifecycle.isClosed) return
+ if (!disconnectConnection(connectionToken, waitForStopped = false, isPermanent = false)) return
+
+ val uptime = if (stats.connectedAt > 0) nowMillis - stats.connectedAt else 0
+ thrown?.let { error -> Logger.w(error) { "[$address] Serial error after ${uptime}ms: ${error.message}" } }
+ Logger.w {
+ "[$address] Serial device disconnected - Device: $device, Uptime: ${uptime}ms, " +
+ "Packets RX: ${stats.packetsReceived} (${stats.bytesReceived} bytes)"
+ }
+ }
+
+ private fun publishConnection(connectionToken: ConnectionToken, connection: SerialConnection): Boolean =
+ synchronized(connectionAdmissionLock) {
+ val generationAvailable =
+ !lifecycle.isClosed &&
+ !connectionCleanupInProgress &&
+ activeConnectionToken == null &&
+ connRef.get() == null
+ if (generationAvailable) {
+ connectionToken.connection = connection
+ connRef.set(connection)
+ activeConnectionToken = connectionToken
+ connectionReady.set(false)
+ }
+ generationAvailable
+ }
+
+ private fun finishConnectionCleanup(completion: CompletableDeferred<Unit>) {
+ synchronized(connectionAdmissionLock) {
+ if (connectionCleanupCompletion === completion) {
+ connectionCleanupCompletion = null
+ connectionCleanupInProgress = false
+ }
+ }
+ completion.complete(Unit)
+ }
+
+ override fun handleSendToRadio(p: ByteArray): Boolean {
+ val transportLease = lifecycle.tryAcquire()
+ val operation =
+ transportLease?.let {
+ val token = synchronized(connectionAdmissionLock) { activeConnectionToken }
+ token?.let { active -> admitConnectionOperation(active, requireReady = true) }
+ }
+ return if (transportLease == null || operation == null) {
+ transportLease?.release()
+ Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" }
+ false
+ } else {
+ queueFramedSend(
+ payload = p,
+ writer = { bytes -> operation.connection.sendBytes(bytes) },
+ onCompletion = {
+ operation.lease.release()
+ transportLease.release()
+ },
+ )
+ }
+ }
+
+ private fun admitConnectionOperation(token: ConnectionToken, requireReady: Boolean): ConnectionOperation? =
+ synchronized(connectionAdmissionLock) {
+ val connection = connRef.get()
+ val ready = !requireReady || connectionReady.get()
+ if (connection == null || !ownsConnectionLocked(token, connection) || !ready) {
+ null
+ } else {
+ token.lifecycle.tryAcquire()?.let { lease -> ConnectionOperation(token, connection, lease) }
+ }
+ }
+
override fun keepAlive() {
- // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial
- // link is alive and keep the local node's lastHeard timestamp current.
scope.handledLaunch { heartbeatSender.sendHeartbeat() }
}
override fun sendBytes(p: ByteArray) {
- val conn = connRef.get()
- if (conn != null) {
- Logger.d { "[$address] Serial sending ${p.size} bytes" }
- conn.sendBytes(p)
- } else {
+ // Raw stream writes are part of the pre-handshake wake/framing path, so they may run before the connection
+ // generation reports ready. Normal packet sends through handleSendToRadio require a ready generation.
+ val transportLease = lifecycle.tryAcquire()
+ val token = synchronized(connectionAdmissionLock) { activeConnectionToken }
+ val operation =
+ transportLease?.let { token?.let { active -> admitConnectionOperation(active, requireReady = false) } }
+ if (transportLease == null || operation == null) {
+ transportLease?.release()
Logger.w { "[$address] Serial connection not available, cannot send ${p.size} bytes" }
+ return
+ }
+ try {
+ Logger.d { "[$address] Serial queueing ${p.size} bytes" }
+ operation.connection.sendBytes(p)
+ } finally {
+ operation.lease.release()
+ transportLease.release()
}
}
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
index 611ea9900d..3f4baf12c9 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -20,9 +20,12 @@ package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
@@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@@ -92,6 +96,12 @@ private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds
*/
internal val SCAN_TIMEOUT = 5.seconds
private val GATT_CLEANUP_TIMEOUT = 5.seconds
+private val BLE_WRITE_OPERATION_TIMEOUT = 10.seconds
+private const val BLE_MAX_PENDING_WRITES = 4
+private val BLE_OPERATION_DRAIN_TIMEOUT = 45.seconds
+
+// An active profile and one predecessor cleanup can each consume a drain budget before their bounded GATT releases.
+private val BLE_TEARDOWN_TIMEOUT = BLE_OPERATION_DRAIN_TIMEOUT * 2 + GATT_CLEANUP_TIMEOUT * 2 + 5.seconds
/**
* Bounded wait for the connectionState StateFlow to reflect Connected after connectAndAwait returns.
@@ -162,22 +172,19 @@ class BleRadioTransport(
private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
if (throwable !is CancellationException) {
- // Record the cause BEFORE the CAS so there is no window where another coroutine
- // sees sessionFailed == true but sessionFailureCause == null. first-cause is
- // preserved: a concurrent loser only overwrites if still null.
- recordSessionFailureCause(throwable)
- if (sessionFailed.compareAndSet(expect = false, update = true)) {
- radioService = null
- isFullyConnected = false
- val (isPermanent, msg) = throwable.toDisconnectReason()
- callback.onDisconnect(isPermanent, errorMessage = msg)
- }
- }
- cleanupScope.launch {
- try {
- bleConnection.disconnect()
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
+ val session = activeSession.value
+ if (session != null) {
+ handleFailure(throwable, session)
+ } else {
+ cleanupScope.launch {
+ awaitPendingSessionCleanup()
+ // A replacement can publish while cleanup is pending; never disconnect its GATT handle.
+ if (activeSession.value == null) {
+ disconnectGatt("exception handler")
+ } else {
+ Logger.d { "[$address] Skipping exception-handler GATT release; a new session is active" }
+ }
+ }
}
}
}
@@ -186,6 +193,21 @@ class BleRadioTransport(
CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler)
private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
private val writeMutex: Mutex = Mutex()
+ private val writePermits = Semaphore(BLE_MAX_PENDING_WRITES)
+ private val lifecycle =
+ TransportLifecycleGate(
+ "BLE",
+ operationDrainTimeout = BLE_OPERATION_DRAIN_TIMEOUT,
+ teardownTimeout = BLE_TEARDOWN_TIMEOUT,
+ )
+ private val activeSession = atomic<BleSession?>(null)
+ private val sessionCleanupLock = SynchronizedObject()
+ private var pendingSessionCleanup: Job? = null
+
+ private class BleSession(val profile: MeshtasticRadioProfile) {
+ val lifecycle = TransportLifecycleGate("BLE session", operationDrainTimeout = BLE_OPERATION_DRAIN_TIMEOUT)
+ val failureCause = atomic<Throwable?>(null)
+ }
@Volatile private var connectionStartTime: Long = 0
@@ -207,12 +229,6 @@ class BleRadioTransport(
// only the caller that wins the compareAndSet(false, true) fires onDisconnect.
private val sessionFailed = atomic(false)
- // Captures the exception that caused handleFailure() to tear down the session, so
- // attemptConnection() can distinguish an internal-failure disconnect from a genuine
- // LocalDisconnect (user-initiated or clean close). When non-null, the reconnect policy
- // treats the disconnect as unintentional and applies backoff escalation.
- @Volatile private var sessionFailureCause: Throwable? = null
-
// Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
// own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
// toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
@@ -220,10 +236,10 @@ class BleRadioTransport(
private val heartbeatSender =
HeartbeatSender(
- sendToRadio = ::handleSendToRadio,
+ sendToRadio = { handleSendToRadio(it) },
afterHeartbeat = {
delay(HEARTBEAT_DRAIN_DELAY)
- radioService?.requestDrain()
+ activeSession.value?.profile?.requestDrain()
},
logTag = address,
)
@@ -350,10 +366,10 @@ class BleRadioTransport(
@Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount")
private suspend fun attemptConnection(): BleReconnectPolicy.Outcome {
connectionStartTime = nowMillis
- sessionFailed.value = false
- sessionFailureCause = null
Logger.i { "[$address] BLE connection attempt started" }
+ awaitPendingSessionCleanup()
+ sessionFailed.value = false
val device = findDevice()
bondDeviceBeforeConnect(device)
@@ -391,15 +407,13 @@ class BleRadioTransport(
isFullyConnected = true
onConnected()
- discoverServicesAndSetupCharacteristics()
+ val session = discoverServicesAndSetupCharacteristics()
// If a fatal session failure (fromRadio/logRadio error) forced disconnect during setup,
// skip the Connected gate — return a retryable failure so BleReconnectPolicy handles it.
- if (sessionFailureCause != null) {
- Logger.w(sessionFailureCause) {
- "[$address] Session failed during profile setup — returning failed outcome"
- }
- return BleReconnectPolicy.Outcome.Failed(sessionFailureCause ?: RuntimeException("Session setup failed"))
+ session.failureCause.value?.let { failure ->
+ Logger.w(failure) { "[$address] Session failed during profile setup — returning failed outcome" }
+ return BleReconnectPolicy.Outcome.Failed(failure)
}
// Wait for the StateFlow to actually reflect Connected before watching for the next
@@ -408,30 +422,33 @@ class BleRadioTransport(
// may lag. Without this gate the next .first { Disconnected } below could match the
// *previous* cycle's stale Disconnected value and fire immediately, breaking reconnect.
//
- // RACE GUARD: A fatal fromRadio/logRadio failure can land AFTER the sessionFailureCause
+ // RACE GUARD: A fatal fromRadio/logRadio failure can land AFTER the session failure-cause
// check above but BEFORE connectionState reaches Connected. handleFailure() forces
// bleConnection.disconnect(), which should emit Disconnected — but if the StateFlow was
// already at a stale Disconnected value, it may NOT re-emit (StateFlow suppresses
// duplicate values). A bounded timeout ensures we cannot hang: if Connected doesn't
- // arrive within CONNECTED_GATE_TIMEOUT, we check sessionFailureCause and return a
+ // arrive within CONNECTED_GATE_TIMEOUT, we check this session's failure cause and return a
// retryable Failed outcome.
val connectedReached =
withTimeoutOrNull(CONNECTED_GATE_TIMEOUT) {
bleConnection.connectionState.first { it is BleConnectionState.Connected }
}
if (connectedReached == null) {
- val failure = sessionFailureCause ?: RuntimeException("Timed out waiting for Connected state gate")
+ val failure = session.failureCause.value ?: RuntimeException("Timed out waiting for Connected state gate")
Logger.w(failure) { "[$address] Session failed before Connected gate — returning failed outcome" }
- // CRITICAL: force GATT cleanup before returning Failed so we don't start a new
- // attempt over an uncleared session. Without this, a timeout caused by flow lag or
- // a stale-Disconnected state mismatch would leave a live/half-live GATT handle behind.
- radioService = null
+ // Force cleanup only for this exact profile generation. If another path already retired it, await that
+ // generation's cleanup instead of issuing a second disconnect that could race later lifecycle work.
isFullyConnected = false
withContext(NonCancellable) {
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed during Connected-gate timeout cleanup" }
+ val retired = retireActiveSession(session)
+ if (retired != null) {
+ runSerializedSessionCleanup(
+ retired,
+ phase = "Connected-gate timeout cleanup",
+ disconnectGatt = true,
+ )
+ } else {
+ awaitPendingSessionCleanup()
}
}
return BleReconnectPolicy.Outcome.Failed(failure)
@@ -446,7 +463,7 @@ class BleRadioTransport(
val disconnectReason = disconnectedState.reason
if (isFullyConnected) {
isFullyConnected = false
- onDisconnected()
+ onDisconnected(session)
}
Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" }
@@ -454,7 +471,7 @@ class BleRadioTransport(
// Internal session failures (write/read exceptions that triggered handleFailure →
// disconnect) must NOT be treated as intentional/user disconnects — the reconnect policy
// needs to escalate backoff for these.
- val internalFailure = sessionFailureCause
+ val internalFailure = session.failureCause.value
if (internalFailure != null) {
Logger.w(internalFailure) { "[$address] Session forced disconnect due to internal failure" }
}
@@ -517,57 +534,54 @@ class BleRadioTransport(
}
}
- private fun onDisconnected() {
- radioService = null
- // Atomic first-writer-wins: if handleFailure already claimed this session's failure
- // callback (or another onDisconnected raced), CAS returns false and we skip the
- // duplicate. The forced disconnect() from handleFailure causes Kable to emit
- // Disconnected, which routes here — without this guard the UI would see two
- // onDisconnect calls (one with the real error message from handleFailure, one
- // generic from here).
+ private fun onDisconnected(expectedSession: BleSession) {
+ val retired = retireActiveSession(expectedSession) ?: return
+ scheduleSessionCleanup(retired, disconnectGatt = false, phase = "remote disconnect")
+ // Atomic first-writer-wins: if another failure already claimed this session's callback, skip the duplicate.
val firstWriter = sessionFailed.compareAndSet(expect = false, update = true)
Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" }
- if (firstWriter) {
- // Signal immediately so the UI reflects the disconnect while reconnect continues.
- callback.onDisconnect(isPermanent = false)
- }
+ if (firstWriter) callback.onDisconnect(isPermanent = false)
}
@Suppress("LongMethod", "ThrowsCount")
- private suspend fun discoverServicesAndSetupCharacteristics() {
+ private suspend fun discoverServicesAndSetupCharacteristics(): BleSession {
+ var setupSession: BleSession? = null
try {
bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
val radioService = service.toMeshtasticRadioProfile()
+ val session = BleSession(radioService)
+ setupSession = session
+ check(activeSession.compareAndSet(expect = null, update = session)) {
+ "BLE profile published while another profile generation is still active"
+ }
radioService.fromRadio
.onEach { packet ->
Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
+ dispatchPacket(packet, session)
}
.catch { e ->
Logger.w(e) { "[$address] Error in fromRadio flow" }
- handleFailure(e)
+ handleFailure(e, session)
}
.launchIn(this)
radioService.logRadio
.onEach { packet ->
Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
+ dispatchPacket(packet, session)
}
.catch { e ->
Logger.w(e) { "[$address] Error in logRadio flow" }
- handleFailure(e)
+ handleFailure(e, session)
}
.launchIn(this)
- this@BleRadioTransport.radioService = radioService
-
Logger.i { "[$address] Profile service active and characteristics subscribed" }
// Wait for FROMNUM CCCD write before triggering the Meshtastic handshake.
// Bounded: if fromRadio fails before subscriptionReady completes, handleFailure
- // sets sessionFailureCause. The timeout also prevents a hang if FROMNUM observe
+ // records this session's failure cause. The timeout also prevents a hang if FROMNUM observe
// never completes for reasons other than a fatal exception (e.g., firmware doesn't
// send CCCD confirmation). We MUST abort setup on timeout — proceeding without
// subscription readiness creates a half-initialized session.
@@ -578,7 +592,8 @@ class BleRadioTransport(
} ?: false
if (!subscriptionReady || sessionFailed.value) {
val cause =
- sessionFailureCause ?: RuntimeException("Timed out waiting for FROMNUM subscription readiness")
+ session.failureCause.value
+ ?: RuntimeException("Timed out waiting for FROMNUM subscription readiness")
Logger.w(cause) {
val reason = if (!subscriptionReady) "timed out" else "failed"
"[$address] Subscription wait $reason — aborting setup"
@@ -605,50 +620,65 @@ class BleRadioTransport(
// - The gate-timeout path returns Outcome.Failed, which drives BleReconnectPolicy
// (Retry backoff immediately; onTransientDisconnect → DeviceSleep once
// consecutiveFailures reaches failureThreshold, default 3).
- // - The timeout path nulls radioService and forces bleConnection.disconnect()
- // under NonCancellable, so handleSendToRadio() fails fast against a null
- // service and the next attempt starts over a clean GATT handle.
+ // - The timeout path retires the active profile generation and forces bleConnection.disconnect()
+ // under NonCancellable, so handleSendToRadio() fails fast once generation admission closes and the
+ // next attempt starts over a clean GATT handle.
// The net worst case is a brief Connected indication while the transport cycles a
// sub-threshold retry — a cosmetic UX lag, not a correctness or data issue.
// Deferring onConnect until after the gate would require a structural refactor of
// the profile-setup callback and introduce its own races, so the current ordering
// is retained.
- if (!sessionFailed.value) {
- this@BleRadioTransport.callback.onConnect()
- } else {
- Logger.w { "[$address] Session failed during setup — skipping onConnect" }
+ val published =
+ if (!sessionFailed.value && activeSession.value === session) {
+ lifecycle.runIfOpen { this@BleRadioTransport.callback.onConnect() } != null
+ } else {
+ false
+ }
+ if (!published) {
+ Logger.w { "[$address] Session failed or transport closed during setup — skipping onConnect" }
}
}
+ return checkNotNull(setupSession) { "BLE profile setup completed without publishing a session" }
} catch (e: CancellationException) {
// Scope was cancelled externally — still ensure GATT cleanup runs so we don't
// leak a BluetoothGatt handle and trigger GATT status 133 on the next attempt.
- radioService = null
- isFullyConnected = false
- withContext(NonCancellable) {
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed during cancellation cleanup" }
- }
- }
+ withContext(NonCancellable) { cleanupProfileSetupFailure("cancellation cleanup", setupSession) }
throw e
} catch (e: Exception) {
Logger.w(e) { "[$address] Profile service discovery or operation failed" }
- // Clear stale state so the next attempt starts clean — if failure happened after
- // radioService assignment but before callback.onConnect(), stale state would survive.
- radioService = null
- isFullyConnected = false
- withContext(NonCancellable) {
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed after profile error" }
- }
- }
+ // Retire any partially-published profile so the next attempt starts clean. Without this, a failure after
+ // profile publication but before callback.onConnect() could leave a stale generation behind.
+ withContext(NonCancellable) { cleanupProfileSetupFailure("profile error cleanup", setupSession) }
throw e // Re-throw so attemptConnection() returns Outcome.Failed(e) for policy backoff.
}
}
+ private suspend fun cleanupProfileSetupFailure(phase: String, expectedSession: BleSession?) {
+ val currentSession = activeSession.value
+ if (expectedSession != null && currentSession != null && currentSession !== expectedSession) {
+ Logger.w { "[$address] Ignoring $phase from an unpublished BLE profile generation" }
+ return
+ }
+
+ isFullyConnected = false
+ if (expectedSession == null) {
+ if (currentSession == null) {
+ awaitPendingSessionCleanup()
+ disconnectGatt(phase)
+ }
+ return
+ }
+
+ val retired = retireActiveSession(expectedSession)
+ if (retired != null) {
+ runSerializedSessionCleanup(retired, phase = phase, disconnectGatt = true)
+ } else {
+ // Another failure path may already own cleanup for this exact generation. Wait for it, but never disconnect
+ // a replacement profile that became active after the failing generation retired.
+ awaitPendingSessionCleanup()
+ }
+ }
+
/**
* Requests high BLE connection priority for the initial config burst, then schedules a downgrade to balanced
* priority after [PRIORITY_DOWNGRADE_DELAY] to conserve battery.
@@ -667,8 +697,6 @@ class BleRadioTransport(
}
}
- @Volatile private var radioService: MeshtasticRadioProfile? = null
-
// --- RadioTransport Implementation ---
/**
@@ -682,49 +710,76 @@ class BleRadioTransport(
* [retryBleOperation]'s 3-attempt retry before reaching this catch.
*
* @param p The packet to send.
+ * @return true when the current BLE profile generation accepted responsibility for the bounded write attempt.
+ * Delivery is confirmed later by the protocol, not by this result.
*/
- override fun handleSendToRadio(p: ByteArray) {
- // Fast-path check: skip coroutine launch entirely if no transport is active.
- if (radioService == null) {
- Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
- return
- }
- connectionScope.launch {
- writeMutex.withLock {
- // Re-read radioService UNDER the lock — handleFailure may have nulled it
- // between the outer check and lock acquisition. Without this, a queued send
- // can retry writes against a stale/dead profile.
- val currentService =
- radioService
- ?: run {
- Logger.w { "[$address] toRadio characteristic cleared during write queue" }
- return@withLock
- }
- try {
- retryBleOperation(tag = address, retryWhile = { currentService === radioService }) {
- currentService.sendToRadio(p)
- }
- val sent = packetsSent.incrementAndGet()
- val txBytes = bytesSent.addAndGet(p.size.toLong())
- Logger.v {
- "[$address] Wrote packet #$sent " + "to toRadio (${p.size} bytes, total TX: $txBytes bytes)"
- }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- // Guard: only call handleFailure if this write was against the CURRENT session.
- // If radioService was replaced (new reconnect cycle) or cleared (handleFailure
- // already ran), this is a stale write from the old session — silently discard.
- if (currentService === radioService) {
- Logger.w(e) {
- "[$address] Failed to write packet to toRadioCharacteristic after " +
- "${packetsSent.value} successful writes"
- }
- handleFailure(e)
+ override fun handleSendToRadio(p: ByteArray): Boolean {
+ val transportLease = lifecycle.tryAcquire()
+ return if (transportLease == null) {
+ false
+ } else {
+ val session = activeSession.value
+ val sessionLease = session?.lifecycle?.tryAcquire()
+ val writePermit = session != null && sessionLease != null && writePermits.tryAcquire()
+ if (session == null || sessionLease == null || !writePermit || activeSession.value !== session) {
+ if (writePermit) writePermits.release()
+ sessionLease?.release()
+ transportLease.release()
+ Logger.w {
+ if (!writePermit && session != null && sessionLease != null) {
+ "[$address] BLE write backlog is full, cannot admit ${p.size} bytes"
} else {
- Logger.d(e) { "[$address] Stale write failure ignored because the session was replaced" }
+ "[$address] toRadio characteristic unavailable, cannot send ${p.size} bytes"
}
}
+ false
+ } else {
+ val sendJob = connectionScope.launch { runBleWrite(session, p) }
+ sendJob.invokeOnCompletion {
+ writePermits.release()
+ sessionLease.release()
+ transportLease.release()
+ }
+ !sendJob.isCancelled
+ }
+ }
+ }
+
+ private suspend fun runBleWrite(session: BleSession, packet: ByteArray) {
+ // Admission bounds the number of queued writes. Start the per-write timeout only after this operation reaches
+ // the front of that bounded queue, so normal backlog does not masquerade as a dead BLE link. Four admitted
+ // writes at the worst-case 10s write bound fit inside the 45s lifecycle drain budget.
+ val completed =
+ writeMutex.withLock {
+ withTimeoutOrNull(BLE_WRITE_OPERATION_TIMEOUT) {
+ writePacket(session, packet)
+ true
+ } == true
+ }
+ if (!completed && activeSession.value === session) {
+ handleFailure(RadioNotConnectedException("BLE write timed out after $BLE_WRITE_OPERATION_TIMEOUT"), session)
+ }
+ }
+
+ private suspend fun writePacket(session: BleSession, packet: ByteArray) {
+ try {
+ retryBleOperation(tag = address, retryWhile = { activeSession.value === session }) {
+ session.profile.sendToRadio(packet)
+ }
+ val sent = packetsSent.incrementAndGet()
+ val txBytes = bytesSent.addAndGet(packet.size.toLong())
+ Logger.v { "[$address] Wrote packet #$sent to toRadio (${packet.size} bytes, total TX: $txBytes bytes)" }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ if (activeSession.value === session) {
+ Logger.w(e) {
+ "[$address] Failed to write packet to toRadioCharacteristic after " +
+ "${packetsSent.value} successful writes"
+ }
+ handleFailure(e, session)
+ } else {
+ Logger.d(e) { "[$address] Stale write failure ignored because the session was replaced" }
}
}
}
@@ -738,79 +793,116 @@ class BleRadioTransport(
/** Closes the connection to the device. */
override suspend fun close() {
- Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
- connectionScope.cancel()
- // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it,
- // which would leak BluetoothGatt and trigger status 133 on the next reconnect.
- // Using withContext (not runBlocking) keeps the caller's thread free — this is
- // critical when close() is invoked from the main thread during process shutdown.
- withContext(NonCancellable) {
- try {
- withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in close()" }
+ var completed = false
+ try {
+ completed =
+ lifecycle.close {
+ // Closing the outer gate rejects new sends while allowing writes admitted before close to finish.
+ // Once those leases drain, cancel reconnect/heartbeat work before retiring the profile and GATT.
+ connectionScope.cancel()
+ Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
+ val session = retireActiveSession()
+ val sessionClosed = session?.lifecycle?.close() ?: true
+ awaitPendingSessionCleanup()
+ disconnectGatt("close")
+ if (!sessionClosed) {
+ Logger.w { "[$address] BLE profile teardown did not complete within its lifecycle bounds" }
+ }
+ }
+ if (!completed) Logger.w { "[$address] BLE teardown did not complete within its lifecycle bounds" }
+ } finally {
+ if (!completed) {
+ // The cleanup scope is detached, so a timed-out outer gate needs one final bounded GATT release attempt
+ // before that scope is retired. This does not claim to preempt blocking native I/O.
+ withContext(NonCancellable) { disconnectGatt("close fallback") }
}
+ cleanupScope.cancel()
}
- // Our own disconnect succeeded — the exception-handler safety net is no longer
- // needed. Cancel the detached cleanup scope so it doesn't outlive us in tests
- // or process lifetime.
- cleanupScope.cancel()
}
- private fun dispatchPacket(packet: ByteArray) {
- val received = packetsReceived.incrementAndGet()
- val rxBytes = bytesReceived.addAndGet(packet.size.toLong())
- Logger.v { "[$address] Dispatching packet #$received " + "(${packet.size} bytes, total RX: $rxBytes bytes)" }
- callback.handleFromRadio(packet)
+ private fun dispatchPacket(packet: ByteArray, expectedSession: BleSession) {
+ val dispatched =
+ expectedSession.lifecycle.runIfOpen {
+ if (activeSession.value !== expectedSession) return@runIfOpen false
+ val received = packetsReceived.incrementAndGet()
+ val rxBytes = bytesReceived.addAndGet(packet.size.toLong())
+ Logger.v {
+ "[$address] Dispatching packet #$received " + "(${packet.size} bytes, total RX: $rxBytes bytes)"
+ }
+ callback.handleFromRadio(packet)
+ true
+ } == true
+ if (!dispatched) Logger.d { "Dropping packet from a retired BLE profile generation" }
}
- /**
- * Preserves the first session-failure cause across concurrent failures. Called before the [sessionFailed] CAS in
- * [handleFailure] and [exceptionHandler] to eliminate the window where [sessionFailed] is true but
- * [sessionFailureCause] is still null.
- */
- private fun recordSessionFailureCause(throwable: Throwable) {
- if (sessionFailureCause == null) sessionFailureCause = throwable
+ /** Preserves the first failure cause on the exact profile generation that observed it. */
+ private fun recordSessionFailureCause(throwable: Throwable, expectedSession: BleSession) {
+ expectedSession.failureCause.compareAndSet(expect = null, update = throwable)
}
- private fun handleFailure(throwable: Throwable) {
- // CancellationException signals intentional scope cancellation (close() called).
- // Never surface it as a user-facing disconnect error.
+ private fun handleFailure(throwable: Throwable, expectedSession: BleSession) {
if (throwable is CancellationException) return
+ recordSessionFailureCause(throwable, expectedSession)
+ val retired = retireActiveSession(expectedSession)
+ if (retired == null) {
+ Logger.d(throwable) { "[$address] Ignoring failure from a retired BLE profile generation" }
+ return
+ }
+ val firstFailure = sessionFailed.compareAndSet(expect = false, update = true)
+ isFullyConnected = false
+ if (firstFailure) {
+ val (isPermanent, msg) = throwable.toDisconnectReason()
+ callback.onDisconnect(isPermanent, errorMessage = if (isPermanent) msg else null)
+ }
+ Logger.w(throwable) { "[$address] Session failure — forcing cleanup for reconnect" }
+ scheduleSessionCleanup(retired, disconnectGatt = true, phase = "session failure")
+ }
- // Record the cause BEFORE the CAS so there is no window where another coroutine
- // sees sessionFailed == true but sessionFailureCause == null. first-cause is
- // preserved: a concurrent loser only overwrites if still null.
- recordSessionFailureCause(throwable)
-
- // Deduplicate via atomic CAS: only the first failure per connection attempt triggers
- // session teardown. Heartbeat writes that arrive after the first failure must not spam
- // callbacks. compareAndSet(false, true) returns true iff THIS caller is the first.
- if (!sessionFailed.compareAndSet(expect = false, update = true)) return
+ private fun retireActiveSession(expected: BleSession? = activeSession.value): BleSession? {
+ expected ?: return null
+ return expected.takeIf { activeSession.compareAndSet(expect = it, update = null) }
+ }
- // Tear down stale session state immediately so future writes fail-fast without retrying
- // against a dead GATT handle.
- radioService = null
- isFullyConnected = false
+ private fun scheduleSessionCleanup(session: BleSession, disconnectGatt: Boolean, phase: String) {
+ val cleanup =
+ synchronized(sessionCleanupLock) {
+ val predecessor = pendingSessionCleanup
+ cleanupScope
+ .launch(start = CoroutineStart.LAZY) {
+ predecessor?.join()
+ val completed =
+ if (disconnectGatt) {
+ session.lifecycle.close { disconnectGatt(phase) }
+ } else {
+ session.lifecycle.close()
+ }
+ if (!completed) Logger.w { "[$address] BLE profile cleanup timed out during $phase" }
+ }
+ .also { pendingSessionCleanup = it }
+ }
+ cleanup.invokeOnCompletion {
+ synchronized(sessionCleanupLock) { if (pendingSessionCleanup === cleanup) pendingSessionCleanup = null }
+ }
+ cleanup.start()
+ }
- val (isPermanent, msg) = throwable.toDisconnectReason()
- // Silent recovery for non-permanent failures: the transport tears down stale GATT state
- // and reconnects automatically, so surfacing a modal for a transient session failure is
- // confusing UX. Permanent failures (pairing, missing characteristic, etc.) remain
- // user-facing.
- callback.onDisconnect(isPermanent, errorMessage = if (isPermanent) msg else null)
+ /** Registers [session] in the serialized cleanup chain and waits until its cleanup is complete. */
+ private suspend fun runSerializedSessionCleanup(session: BleSession, phase: String, disconnectGatt: Boolean) {
+ scheduleSessionCleanup(session, disconnectGatt = disconnectGatt, phase = phase)
+ awaitPendingSessionCleanup()
+ }
- Logger.w(throwable) { "[$address] Session failure — forcing cleanup for reconnect" }
+ private suspend fun awaitPendingSessionCleanup() {
+ synchronized(sessionCleanupLock) { pendingSessionCleanup }?.join()
+ }
- // Force GATT disconnect on the detached cleanupScope (matching the pattern used by
- // the exceptionHandler defined above). This causes Kable's connectionState
- // to emit Disconnected, unblocking attemptConnection so BleReconnectPolicy iterates.
- cleanupScope.launch {
- try {
- bleConnection.disconnect()
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect after session failure" }
- }
+ private suspend fun disconnectGatt(phase: String) {
+ try {
+ withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Failed to disconnect during $phase" }
}
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
index 7440aee9ca..25b4154a62 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockRadioTransport.kt
@@ -18,9 +18,10 @@ package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
-import kotlinx.atomicfu.update
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import okio.ByteString.Companion.encodeUtf8
import okio.ByteString.Companion.toByteString
@@ -103,32 +104,28 @@ class MockRadioTransport(
/** Guards against re-seeding traffic if the app repeats stage 2 (e.g. after a handshake retry). */
private val trafficStarted = atomic(false)
- /**
- * Every coroutine this transport owns: the live-traffic ticker, the delayed replies and the delayed acks.
- *
- * An atomic reference to an immutable list rather than a `mutableListOf`, because [close] drains it from the
- * caller's context while `handleSendToRadio` is still appending to it — concurrent iteration and mutation of a
- * plain list throws ConcurrentModificationException.
- */
- private val pendingJobs = atomic<List<Job>>(emptyList())
+ private val lifecycle = TransportLifecycleGate("Mock")
+ private val transportJob = SupervisorJob(scope.coroutineContext[Job])
+ private val transportScope = CoroutineScope(scope.coroutineContext + transportJob)
private fun nextPacketId(): Int = packetIdCounter.getAndIncrement()
- /** Registers a coroutine for cancellation by [close], dropping the ones that have already finished. */
- private fun track(job: Job) {
- pendingJobs.update { jobs -> jobs.filterNot { it.isCompleted } + job }
- }
-
override fun start() {
- Logger.i { "Starting the mock transport" }
- callback.onConnect() // Tell clients they can use the API
+ lifecycle.runIfOpen {
+ Logger.i { "Starting the mock transport" }
+ callback.onConnect() // Tell clients they can use the API
+ }
}
- override fun handleSendToRadio(p: ByteArray) {
- val pr = ToRadio.ADAPTER.decode(p)
+ override fun handleSendToRadio(p: ByteArray): Boolean = lifecycle.runIfOpen {
+ val pr = runCatching { ToRadio.ADAPTER.decode(p) }.getOrNull()
+ if (pr == null) {
+ Logger.w { "Ignoring undecodable ToRadio sent to mock transport (${p.size} bytes)" }
+ return@runIfOpen false
+ }
- // Intercept the want_config handshake. Real firmware answers each stage separately and only when asked, and the
- // app's state machine depends on that: see the class doc.
+ // Intercept the want_config handshake. Real firmware answers each stage separately and only when asked,
+ // and the app's state machine depends on that: see the class doc.
when (pr.want_config_id) {
null,
0,
@@ -140,7 +137,8 @@ class MockRadioTransport(
else -> Logger.w { "Mock transport ignoring unknown want_config_id ${pr.want_config_id}" }
}
- }
+ true
+ } ?: false
private fun handleOutboundTraffic(pr: ToRadio) {
val packet = pr.packet
@@ -151,8 +149,14 @@ class MockRadioTransport(
val data = packet?.decoded
when {
- data != null && data.portnum == PortNum.ADMIN_APP ->
- handleAdminPacket(pr, AdminMessage.ADAPTER.decode(data.payload))
+ data != null && data.portnum == PortNum.ADMIN_APP -> {
+ val admin = runCatching { AdminMessage.ADAPTER.decode(data.payload) }.getOrNull()
+ if (admin == null) {
+ Logger.w { "Ignoring undecodable AdminMessage sent to mock transport" }
+ } else {
+ handleAdminPacket(pr, admin)
+ }
+ }
data != null && data.portnum == PortNum.TEXT_MESSAGE_APP -> {
if (packet?.want_ack == true) sendFakeAck(pr)
@@ -203,10 +207,12 @@ class MockRadioTransport(
}
override suspend fun close() {
- Logger.i { "Closing the mock transport" }
- // Drain and cancel in one atomic swap so a job added concurrently is either cancelled here or belongs to the
- // list the next close() drains — never silently dropped while still running.
- pendingJobs.getAndSet(emptyList()).forEach { it.cancel() }
+ val completed =
+ lifecycle.close(
+ beforeDrain = { transportJob.cancelAndJoin() },
+ teardown = { Logger.i { "Closing the mock transport" } },
+ )
+ if (!completed) Logger.w { "Mock transport teardown did not complete within its lifecycle bounds" }
}
// ── Handshake ─────────────────────────────────────────────────────────────────────────────
@@ -246,7 +252,7 @@ class MockRadioTransport(
callback.handleFromRadio(FromRadio(config_complete_id = HandshakeConstants.NODE_INFO_NONCE).encode())
if (trafficStarted.compareAndSet(expect = false, update = true)) {
- track(scope.handledLaunch { seedTraffic() })
+ transportScope.handledLaunch { seedTraffic() }
}
}
@@ -305,53 +311,61 @@ class MockRadioTransport(
*/
private suspend fun seedTraffic() {
SIM_PEERS.forEach { peer ->
- callback.handleFromRadio(peer.positionPacket(nextPacketId()).encode())
+ lifecycle.runIfOpen { callback.handleFromRadio(peer.positionPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
}
SIM_PEERS.take(TELEMETRY_PEER_COUNT).forEach { peer ->
- callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick = 0).encode())
+ lifecycle.runIfOpen {
+ callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick = 0).encode())
+ }
delay(SEED_SPACING_MS)
}
WEATHER_PEER_INDEXES.forEach { index ->
val peer = SIM_PEERS[index]
- callback.handleFromRadio(peer.environmentTelemetryPacket(nextPacketId(), tick = 0).encode())
+ lifecycle.runIfOpen {
+ callback.handleFromRadio(peer.environmentTelemetryPacket(nextPacketId(), tick = 0).encode())
+ }
delay(SEED_SPACING_MS)
}
- callback.handleFromRadio(SIM_PEERS[0].neighborInfoPacket(nextPacketId()).encode())
+ lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[0].neighborInfoPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
- callback.handleFromRadio(SIM_PEERS[1].nodeStatusPacket(nextPacketId()).encode())
+ lifecycle.runIfOpen { callback.handleFromRadio(SIM_PEERS[1].nodeStatusPacket(nextPacketId()).encode()) }
delay(SEED_SPACING_MS)
// Each message is stamped progressively closer to now, so the thread reads as a conversation that unfolded over
// the last while rather than a block of messages that all arrived in the same second.
CHANNEL_CONVERSATION.forEachIndexed { index, (peerIndex, text) ->
val peer = SIM_PEERS[peerIndex]
- callback.handleFromRadio(
- peer
- .textPacket(
- id = nextPacketId(),
- to = BROADCAST_ADDR,
- text = text,
- ageSeconds = messageAgeSeconds(CHANNEL_CONVERSATION.size, index),
- )
- .encode(),
- )
+ lifecycle.runIfOpen {
+ callback.handleFromRadio(
+ peer
+ .textPacket(
+ id = nextPacketId(),
+ to = BROADCAST_ADDR,
+ text = text,
+ ageSeconds = messageAgeSeconds(CHANNEL_CONVERSATION.size, index),
+ )
+ .encode(),
+ )
+ }
delay(SEED_SPACING_MS)
}
DIRECT_CONVERSATION.forEachIndexed { index, text ->
- callback.handleFromRadio(
- SIM_PEERS[DIRECT_PEER_INDEX].textPacket(
- id = nextPacketId(),
- to = MY_NODE,
- text = text,
- ageSeconds = messageAgeSeconds(DIRECT_CONVERSATION.size, index),
+ lifecycle.runIfOpen {
+ callback.handleFromRadio(
+ SIM_PEERS[DIRECT_PEER_INDEX].textPacket(
+ id = nextPacketId(),
+ to = MY_NODE,
+ text = text,
+ ageSeconds = messageAgeSeconds(DIRECT_CONVERSATION.size, index),
+ )
+ .encode(),
)
- .encode(),
- )
+ }
delay(SEED_SPACING_MS)
}
@@ -370,10 +384,12 @@ class MockRadioTransport(
while (true) {
delay(LIVE_TICK_MS)
val peer = SIM_PEERS[tick % TELEMETRY_PEER_COUNT]
- callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick).encode())
+ lifecycle.runIfOpen { callback.handleFromRadio(peer.deviceTelemetryPacket(nextPacketId(), tick).encode()) }
if (tick % WEATHER_TICK_INTERVAL == 0) {
val weatherPeer = SIM_PEERS[WEATHER_PEER_INDEXES.first()]
- callback.handleFromRadio(weatherPeer.environmentTelemetryPacket(nextPacketId(), tick).encode())
+ lifecycle.runIfOpen {
+ callback.handleFromRadio(weatherPeer.environmentTelemetryPacket(nextPacketId(), tick).encode())
+ }
}
tick++
}
@@ -394,16 +410,16 @@ class MockRadioTransport(
}
val replyTo = if (isBroadcast) BROADCAST_ADDR else MY_NODE
- track(
- scope.handledLaunch {
- delay(REPLY_DELAY_MS)
+ transportScope.handledLaunch {
+ delay(REPLY_DELAY_MS)
+ lifecycle.runIfOpen {
callback.handleFromRadio(
responder
.textPacket(id = nextPacketId(), to = replyTo, text = AUTO_REPLY_TEXT, ageSeconds = 0)
.encode(),
)
- },
- )
+ }
+ }
}
// ── Packet builders ──────────────────────────────────────────────────────────────────────
@@ -597,12 +613,12 @@ class MockRadioTransport(
// / Send a fake ack packet back if the sender asked for want_ack
private fun sendFakeAck(pr: ToRadio) {
val packet = pr.packet ?: return
- track(
- scope.handledLaunch {
- delay(ACK_DELAY_MS)
+ transportScope.handledLaunch {
+ delay(ACK_DELAY_MS)
+ lifecycle.runIfOpen {
callback.handleFromRadio(makeAck(SIM_PEERS[DIRECT_PEER_INDEX].num, packet.from, packet.id).encode())
- },
- )
+ }
+ }
}
/** One simulated peer in the demo mesh. */
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
index 9ed224d3fa..e469a6588e 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopRadioTransport.kt
@@ -26,9 +26,7 @@ import org.meshtastic.core.repository.RadioTransport
* the service layer.
*/
class NopRadioTransport(val address: String) : RadioTransport {
- override fun handleSendToRadio(p: ByteArray) {
- // No-op
- }
+ override fun handleSendToRadio(p: ByteArray): Boolean = false
override suspend fun close() {
// No-op
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransport.kt
index 22cef02891..58e801d184 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransport.kt
@@ -17,7 +17,12 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import okio.Buffer
import okio.EOFException
@@ -74,9 +79,9 @@ import org.meshtastic.proto.ToRadio
* - **Stage 2** ([HandshakeConstants.NODE_INFO_NONCE]) → emit the node section, then `config_complete_id`, then start
* streaming the packet section **once** (a re-issued Stage-2 request does not restart it).
*
- * The packet stream is paced at [packetDelayMs] and does not loop; it stops when [scope] is cancelled. Outbound
- * [ToRadio] traffic other than want_config (heartbeats, app packets) is ignored — the replay is strictly read-only, and
- * frames are held in memory, so [close] has nothing to release.
+ * The packet stream is paced at [packetDelayMs] and does not loop; it stops when [scope] or this transport is closed.
+ * Outbound [ToRadio] traffic other than want_config (heartbeats, app packets) is ignored — the replay is strictly
+ * read-only. [close] is terminal: it rejects later handshakes and cancels transport-owned replay work.
*
* ### Robustness
* The asset is parsed up front and every length is validated against the bytes actually remaining, so a truncated or
@@ -104,55 +109,84 @@ class ReplayRadioTransport(
packetFrames = buffer.readSection(counted = false, label = "packet")
}
- private var packetsStarted = false
+ private val packetsStarted = atomic(false)
+ private val lifecycle = TransportLifecycleGate("Replay")
+ private val transportJob = SupervisorJob(scope.coroutineContext[Job])
+ private val transportScope = CoroutineScope(scope.coroutineContext + transportJob)
+ private val handshakeQueue = Channel<Int>(capacity = Channel.UNLIMITED)
+ private val started = atomic(false)
override fun start() {
- Logger.i {
- "Starting replay transport: ${configFrames.size} config, ${nodeFrames.size} node, " +
- "${packetFrames.size} packet frames"
+ lifecycle.runIfOpen {
+ if (!started.compareAndSet(expect = false, update = true)) return@runIfOpen
+ // Once start is published, the channel can safely buffer a racing nonce until this worker begins.
+ transportScope.handledLaunch { for (nonce in handshakeQueue) replayHandshake(nonce) }
+ Logger.i {
+ "Starting replay transport: ${configFrames.size} config, ${nodeFrames.size} node, " +
+ "${packetFrames.size} packet frames"
+ }
+ callback.onConnect()
}
- callback.onConnect()
}
- override fun handleSendToRadio(p: ByteArray) {
- // Undecodable ToRadio is ignored rather than thrown: the replay must tolerate any bytes the app — or a fuzz
- // harness — hands it, exactly as it tolerates a malformed asset.
+ override fun handleSendToRadio(p: ByteArray): Boolean = lifecycle.runIfOpen {
+ if (!started.value) return@runIfOpen false
+ // Undecodable ToRadio is ignored rather than thrown: the replay must tolerate any bytes the app — or a
+ // fuzz test harness — hands it, exactly as it tolerates a malformed asset.
val wantConfigId = runCatching { ToRadio.ADAPTER.decode(p).want_config_id }.getOrNull()
when (wantConfigId) {
- HandshakeConstants.CONFIG_NONCE ->
- scope.handledLaunch {
- emit(configFrames)
- complete(HandshakeConstants.CONFIG_NONCE)
- }
+ HandshakeConstants.CONFIG_NONCE,
+ HandshakeConstants.NODE_INFO_NONCE,
+ -> handshakeQueue.trySend(wantConfigId).isSuccess
- HandshakeConstants.NODE_INFO_NONCE ->
- scope.handledLaunch {
- emit(nodeFrames)
- complete(HandshakeConstants.NODE_INFO_NONCE)
- if (!packetsStarted) {
- packetsStarted = true
- streamPackets()
- }
+ // Accepted but intentionally ignored: replay is a read-only sink for ordinary outbound traffic.
+ else -> true
+ }
+ } ?: false
+
+ private suspend fun replayHandshake(nonce: Int) {
+ when (nonce) {
+ HandshakeConstants.CONFIG_NONCE -> {
+ emit(configFrames)
+ complete(HandshakeConstants.CONFIG_NONCE)
+ }
+
+ HandshakeConstants.NODE_INFO_NONCE -> {
+ emit(nodeFrames)
+ complete(HandshakeConstants.NODE_INFO_NONCE)
+ if (packetsStarted.compareAndSet(expect = false, update = true)) {
+ transportScope.handledLaunch { streamPackets() }
}
- // All other ToRadio traffic (heartbeats, outbound packets) is ignored — this is a read-only replay.
+ }
}
}
- private fun emit(frames: List<ByteArray>) = frames.forEach { callback.handleFromRadio(it) }
+ private fun emit(frames: List<ByteArray>) {
+ frames.forEach { frame -> if (lifecycle.runIfOpen { callback.handleFromRadio(frame) } == null) return }
+ }
- private fun complete(nonce: Int) = callback.handleFromRadio(FromRadio(config_complete_id = nonce).encode())
+ private fun complete(nonce: Int) {
+ lifecycle.runIfOpen { callback.handleFromRadio(FromRadio(config_complete_id = nonce).encode()) }
+ }
private suspend fun streamPackets() {
Logger.d { "Replay streaming ${packetFrames.size} packets at ${packetDelayMs}ms spacing" }
for (frame in packetFrames) {
- callback.handleFromRadio(frame)
+ if (lifecycle.runIfOpen { callback.handleFromRadio(frame) } == null) return
if (packetDelayMs > 0) delay(packetDelayMs)
}
Logger.i { "Replay finished (${packetFrames.size} packets)" }
}
override suspend fun close() {
- // Frames live in memory; the streaming coroutine is cancelled with the scope.
+ val completed =
+ lifecycle.close(
+ beforeDrain = {
+ handshakeQueue.close()
+ transportJob.cancelAndJoin()
+ },
+ )
+ if (!completed) Logger.w { "Replay transport teardown did not complete within its lifecycle bounds" }
}
/**
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
index e8225b6305..2764a28b7b 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/StreamTransport.kt
@@ -17,12 +17,21 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportCallback
import org.meshtastic.core.repository.TransportDisconnectReason
+import kotlin.time.Duration.Companion.seconds
+
+private val STREAM_CLOSE_DRAIN_TIMEOUT = 10.seconds
/**
* An interface that assumes we are talking to a meshtastic device over some sort of stream connection (serial or TCP
@@ -33,11 +42,66 @@ import org.meshtastic.core.repository.TransportDisconnectReason
abstract class StreamTransport(protected val callback: RadioTransportCallback, protected val scope: CoroutineScope) :
RadioTransport {
+ private class FramedSend(
+ val payload: ByteArray,
+ val writer: suspend (ByteArray) -> Unit,
+ val flusher: suspend () -> Unit,
+ val onCompletion: () -> Unit,
+ )
+
private val codec =
StreamFrameCodec(onPacketReceived = { callback.handleFromRadio(it) }, logTag = "StreamTransport")
+ private val sendQueue = Channel<FramedSend>(capacity = MAX_PENDING_SENDS)
+ private val sendWorker =
+ scope
+ .handledLaunch {
+ for (send in sendQueue) {
+ val failure =
+ runCatching { codec.frameAndSend(send.payload, send.writer, send.flusher) }.exceptionOrNull()
+ try {
+ when (failure) {
+ null -> Unit
+
+ is CancellationException ->
+ if (!currentCoroutineContext().isActive) {
+ throw failure
+ } else {
+ Logger.w(failure) { "StreamTransport: framed send cancelled" }
+ }
+ is Error -> throw failure
+
+ else -> Logger.w(failure) { "StreamTransport: framed send failed" }
+ }
+ } finally {
+ completeSend(send)
+ }
+ }
+ }
+ .also { worker -> worker.invokeOnCompletion { drainAbandonedSends() } }
+
+ private fun drainAbandonedSends() {
+ sendQueue.close()
+ while (true) {
+ val abandoned = sendQueue.tryReceive().getOrNull() ?: break
+ completeSend(abandoned)
+ }
+ }
+
+ /**
+ * Closes admission and lets already-admitted frames finish before cancelling the worker as a bounded fallback.
+ *
+ * A forced cancellation can interrupt a frame mid-write, so [close] is terminal for the underlying stream: a
+ * subclass must close or replace that stream before another transport instance writes to it.
+ */
override suspend fun close() {
Logger.d { "Closing stream transport" }
+ sendQueue.close()
+ val drained = withTimeoutOrNull(STREAM_CLOSE_DRAIN_TIMEOUT) { sendWorker.join() } != null
+ if (!drained) {
+ Logger.w { "StreamTransport: framed send drain timed out after $STREAM_CLOSE_DRAIN_TIMEOUT" }
+ sendWorker.cancelAndJoin()
+ }
}
/**
@@ -71,19 +135,56 @@ abstract class StreamTransport(protected val callback: RadioTransportCallback, p
callback.onConnect()
}
- /** Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). */
+ /**
+ * Writes raw bytes to the underlying stream (serial port, TCP socket, etc.). Implementations may block until the
+ * driver accepts the bytes, so direct callers must already be running on an I/O dispatcher. Framed packet sends use
+ * [queueFramedSend], whose writer controls dispatcher confinement. Subclasses that rely on [queueFramedSend]'s
+ * default writer must therefore supply an I/O-confined [scope].
+ */
abstract fun sendBytes(p: ByteArray)
/** Flushes buffered bytes to the underlying stream. No-op by default. */
open fun flushBytes() {}
- override fun handleSendToRadio(p: ByteArray) {
- // This method is called from a continuation and it might show up late, so check for uart being null
- scope.handledLaunch { codec.frameAndSend(p, ::sendBytes, ::flushBytes) }
+ /**
+ * Queues the framed packet onto [scope], optionally binding the deferred write to a transport-session resource.
+ *
+ * Capturing the writer at admission keeps a queued write from being redirected to a replacement connection before
+ * its coroutine runs. The result reports only successful scheduling on a live scope; physical delivery is not
+ * confirmed.
+ *
+ * [onCompletion] runs exactly once whether the send is admitted, abandoned during worker teardown, or rejected.
+ */
+ protected fun queueFramedSend(
+ payload: ByteArray,
+ writer: suspend (ByteArray) -> Unit = { sendBytes(it) },
+ flusher: suspend () -> Unit = { flushBytes() },
+ onCompletion: () -> Unit = {},
+ ): Boolean {
+ val send = FramedSend(payload, writer, flusher, onCompletion)
+ val accepted = sendWorker.isActive && sendQueue.trySend(send).isSuccess
+ if (!accepted) completeSend(send)
+ return accepted
+ }
+
+ private fun completeSend(send: FramedSend) {
+ val failure = runCatching(send.onCompletion).exceptionOrNull()
+ when (failure) {
+ null -> Unit
+ is Error -> throw failure
+ else -> Logger.e(failure) { "StreamTransport: framed-send completion failed" }
+ }
}
+ override fun handleSendToRadio(p: ByteArray): Boolean = queueFramedSend(p)
+
/** Process a single incoming byte through the stream framing state machine. */
protected fun readChar(c: Byte) {
codec.processInputByte(c)
}
+
+ internal companion object {
+ /** Upper bound on framed sends awaiting the serialized writer. */
+ const val MAX_PENDING_SENDS = 32
+ }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
index 9a0bd278e8..3fcc197900 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TcpRadioTransport.kt
@@ -17,14 +17,46 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.joinAll
+import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.network.transport.TcpTransport
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportCallback
-import kotlin.concurrent.Volatile
+import kotlin.time.Duration.Companion.seconds
+
+/** Minimal TCP connection surface used by [TcpRadioTransport] to coordinate admission and teardown. */
+internal interface TcpRadioConnection {
+ val isConnected: Boolean
+
+ fun start(address: String)
+
+ fun stop()
+
+ suspend fun sendPacket(payload: ByteArray)
+
+ suspend fun sendHeartbeat()
+}
+
+private class TcpRadioConnectionImpl(private val delegate: TcpTransport) : TcpRadioConnection {
+ override val isConnected: Boolean
+ get() = delegate.isConnected
+
+ override fun start(address: String) = delegate.start(address)
+
+ override fun stop() = delegate.stop()
+
+ override suspend fun sendPacket(payload: ByteArray) = delegate.sendPacket(payload)
+
+ override suspend fun sendHeartbeat() = delegate.sendHeartbeat()
+}
/**
* TCP radio transport — thin adapter over the shared [TcpTransport] from `core:network`.
@@ -33,64 +65,142 @@ import kotlin.concurrent.Volatile
* and calling [RadioTransportCallback] for lifecycle events. This avoids the previous inheritance from
* [StreamTransport] which created a dead [StreamFrameCodec] and required overriding `sendBytes` as a no-op.
*/
-open class TcpRadioTransport(
+open class TcpRadioTransport
+internal constructor(
private val callback: RadioTransportCallback,
private val scope: CoroutineScope,
- private val dispatchers: CoroutineDispatchers,
private val address: String,
+ connectionFactory: (TcpTransport.Listener) -> TcpRadioConnection,
) : RadioTransport {
+ constructor(
+ callback: RadioTransportCallback,
+ scope: CoroutineScope,
+ dispatchers: CoroutineDispatchers,
+ address: String,
+ ) : this(
+ callback = callback,
+ scope = scope,
+ address = address,
+ connectionFactory = { listener ->
+ TcpRadioConnectionImpl(
+ TcpTransport(
+ dispatchers = dispatchers,
+ scope = scope,
+ listener = listener,
+ logTag = "TcpRadioTransport[$address]",
+ ),
+ )
+ },
+ )
+
companion object {
const val SERVICE_PORT = StreamFrameCodec.DEFAULT_TCP_PORT
+ internal val OPERATION_TIMEOUT = 10.seconds
}
- /** Guards against a double [RadioTransportCallback.onDisconnect] when [close] triggers [TcpTransport.stop]. */
- @Volatile private var closing = false
+ private val lifecycle = TransportLifecycleGate("TCP", operationDrainTimeout = OPERATION_TIMEOUT * 2)
+ private val transportStopped = atomic(false)
+ private val operationJobsLock = SynchronizedObject()
+ private val operationJobs = mutableSetOf<Job>()
private val transport =
- TcpTransport(
- dispatchers = dispatchers,
- scope = scope,
- listener =
+ connectionFactory(
object : TcpTransport.Listener {
override fun onConnected() {
- callback.onConnect()
+ lifecycle.runIfOpen { callback.onConnect() }
}
override fun onDisconnected() {
- if (closing) return // close() will fire the permanent disconnect itself
- // TCP disconnects are transient (not permanent) — the transport will auto-reconnect.
- callback.onDisconnect(isPermanent = false)
+ // close() first closes admission, suppressing the transient callback caused by transport.stop().
+ lifecycle.runIfOpen { callback.onDisconnect(isPermanent = false) }
}
override fun onPacketReceived(bytes: ByteArray) {
- callback.handleFromRadio(bytes)
+ lifecycle.runIfOpen { callback.handleFromRadio(bytes) }
}
},
- logTag = "TcpRadioTransport[$address]",
)
override fun start() {
- transport.start(address)
+ lifecycle.runIfOpen {
+ if (transportStopped.value) {
+ Logger.w { "[$address] Ignoring start on a stopped TCP transport; a fresh transport is required" }
+ } else {
+ transport.start(address)
+ }
+ }
}
override suspend fun close() {
Logger.d { "[$address] Closing TCP transport" }
- closing = true
- transport.stop()
- // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the
- // service layer's responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting
- // it from close() caused a double-disconnect and prevented the auto-reconnect loop from
- // owning its own lifecycle. The `closing` guard above suppresses the listener's transient
- // disconnect during teardown.
+ val completed =
+ lifecycle.close(
+ teardown = {
+ stopTransport()
+ cancelOutstandingOperations()
+ },
+ )
+ if (!completed) Logger.w { "[$address] TCP teardown did not complete within its lifecycle bounds" }
+ // Do NOT emit onDisconnect(isPermanent = true) here. The explicit-disconnect signal is the service layer's
+ // responsibility (SharedRadioInterfaceService.stopTransportLocked); emitting it here causes a double-disconnect
+ // and prevents the auto-reconnect loop from owning its transient lifecycle.
}
override fun keepAlive() {
Logger.d { "[$address] TCP keepAlive" }
- scope.handledLaunch { transport.sendHeartbeat() }
+ launchConnectionOperation("heartbeat") { transport.sendHeartbeat() }
+ }
+
+ override fun handleSendToRadio(p: ByteArray): Boolean =
+ launchConnectionOperation("send") { transport.sendPacket(p) }
+
+ /**
+ * Schedules one operation while the current transport is connected.
+ *
+ * The Boolean reports lifecycle admission and successful scheduling only; the operation runs asynchronously and may
+ * still fail or time out after this method returns `true`.
+ */
+ private fun launchConnectionOperation(operation: String, block: suspend () -> Unit): Boolean {
+ val lease = lifecycle.tryAcquire() ?: return false
+ return if (!transport.isConnected) {
+ lease.release()
+ false
+ } else {
+ var completionRegistered = false
+ try {
+ val job =
+ scope.handledLaunch {
+ val completed = withTimeoutOrNull(OPERATION_TIMEOUT) { block() } != null
+ if (!completed) {
+ Logger.w { "[$address] TCP $operation timed out after $OPERATION_TIMEOUT" }
+ // Cancellation may leave a framed write partially emitted. Stopping this one-shot
+ // transport forces the service reconnect path to create a fresh transport before another
+ // send.
+ stopTransport()
+ }
+ }
+ synchronized(operationJobsLock) { operationJobs += job }
+ job.invokeOnCompletion {
+ synchronized(operationJobsLock) { operationJobs -= job }
+ lease.release()
+ }
+ completionRegistered = true
+ !job.isCancelled
+ } finally {
+ if (!completionRegistered) lease.release()
+ }
+ }
+ }
+
+ private suspend fun cancelOutstandingOperations() {
+ val jobs = synchronized(operationJobsLock) { operationJobs.toList() }
+ jobs.forEach { it.cancel() }
+ val joined = withTimeoutOrNull(OPERATION_TIMEOUT) { jobs.joinAll() } != null
+ if (!joined) Logger.w { "[$address] TCP operation jobs did not stop after transport teardown" }
}
- override fun handleSendToRadio(p: ByteArray) {
- scope.handledLaunch { transport.sendPacket(p) }
+ private fun stopTransport() {
+ if (transportStopped.compareAndSet(expect = false, update = true)) transport.stop()
}
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGate.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGate.kt
new file mode 100644
index 0000000000..389d04007a
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGate.kt
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/** Linearizes admitted transport operations with terminal, idempotent teardown. */
+internal class TransportLifecycleGate(
+ private val label: String,
+ private val operationDrainTimeout: Duration = OPERATION_DRAIN_TIMEOUT,
+ private val teardownTimeout: Duration = TEARDOWN_TIMEOUT,
+) {
+ internal companion object {
+ val OPERATION_DRAIN_TIMEOUT = 15.seconds
+ val TEARDOWN_TIMEOUT = 15.seconds
+ }
+
+ private val lock = SynchronizedObject()
+ private val closed = atomic(false)
+ private var admittedOperations = 0
+ private var operationDrainWaiter: CompletableDeferred<Unit>? = null
+ private var closeCompletion: CompletableDeferred<Boolean>? = null
+
+ val isClosed: Boolean
+ get() = closed.value
+
+ /** One admitted operation. Release exactly once when the externally visible work has actually finished. */
+ internal class OperationLease internal constructor(private val releaseAction: () -> Unit) {
+ private val released = atomic(false)
+
+ fun release() {
+ if (released.compareAndSet(expect = false, update = true)) releaseAction()
+ }
+ }
+
+ /** Acquires an operation lease, or returns `null` once close has begun. */
+ fun tryAcquire(): OperationLease? = synchronized(lock) {
+ if (closed.value) return@synchronized null
+ admittedOperations++
+ OperationLease(::releaseOperation)
+ }
+
+ /** Runs [block] outside the gate lock after admission, or returns `null` once close has begun. */
+ fun <T : Any> runIfOpen(block: () -> T): T? {
+ val lease = tryAcquire() ?: return null
+ return try {
+ block()
+ } finally {
+ lease.release()
+ }
+ }
+
+ /**
+ * Closes admission, cooperatively bounds [beforeDrain], drains admitted work, and then bounds [teardown].
+ *
+ * [beforeDrain] lets an owner stop an internal worker whose completion releases operation leases. Admission is
+ * already closed before it runs, so callbacks caused by that preparation cannot escape the gate. Both phases
+ * execute exactly once.
+ *
+ * [NonCancellable] keeps callers from abandoning shared close ownership, while the nested timeouts can still cancel
+ * cooperative work. They cannot bound a teardown lambda that blocks a thread without a cancellation point, or a
+ * nested [close] that has entered its own [NonCancellable] ownership. Owners that nest lifecycle closes must size
+ * their outer timeout to include the nested drain/teardown budget, and potentially blocking platform work must
+ * remain interruptible. Returns `true` only when preparation, the admitted-operation drain, and teardown all
+ * complete within their bounds. A phase failure is recorded on the shared completion and rethrown to every closer;
+ * the gate is deliberately not reopened or retried after a poisoned close.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ suspend fun close(beforeDrain: suspend () -> Unit = {}, teardown: suspend () -> Unit = {}): Boolean =
+ withContext(NonCancellable) {
+ val plan = closePlan()
+ if (!plan.owner) return@withContext plan.completion.await()
+
+ try {
+ val preparation = prepareForClose(beforeDrain)
+ logPreparationResult(preparation)
+ val drained = awaitOperationDrain(plan.operationDrain)
+ val tornDown = runTeardown(teardown)
+ (preparation as? ClosePreparation.Failed)?.failure?.let { throw it }
+
+ val completed = preparation === ClosePreparation.Completed && drained && tornDown
+ plan.completion.complete(completed)
+ completed
+ } catch (failure: Throwable) {
+ plan.completion.completeExceptionally(failure)
+ throw failure
+ } finally {
+ if (!plan.completion.isCompleted) plan.completion.complete(false)
+ }
+ }
+
+ private fun closePlan(): ClosePlan = synchronized(lock) {
+ if (closed.value) {
+ ClosePlan(owner = false, completion = checkNotNull(closeCompletion), operationDrain = null)
+ } else {
+ closed.value = true
+ val completion = CompletableDeferred<Boolean>().also { closeCompletion = it }
+ val operationDrain =
+ if (admittedOperations == 0) {
+ null
+ } else {
+ operationDrainWaiter ?: CompletableDeferred<Unit>().also { operationDrainWaiter = it }
+ }
+ ClosePlan(owner = true, completion = completion, operationDrain = operationDrain)
+ }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun prepareForClose(beforeDrain: suspend () -> Unit): ClosePreparation =
+ withTimeoutOrNull(operationDrainTimeout) {
+ try {
+ beforeDrain()
+ ClosePreparation.Completed
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (failure: Throwable) {
+ ClosePreparation.Failed(failure)
+ }
+ } ?: ClosePreparation.TimedOut
+
+ private fun logPreparationResult(preparation: ClosePreparation) {
+ when (preparation) {
+ ClosePreparation.Completed -> Unit
+
+ ClosePreparation.TimedOut ->
+ Logger.w { "$label transport close preparation timed out after $operationDrainTimeout" }
+
+ is ClosePreparation.Failed -> Logger.w(preparation.failure) { "$label transport close preparation failed" }
+ }
+ }
+
+ private suspend fun awaitOperationDrain(operationDrain: CompletableDeferred<Unit>?): Boolean {
+ val drained =
+ operationDrain == null ||
+ withTimeoutOrNull(operationDrainTimeout) {
+ operationDrain.await()
+ true
+ } == true
+ if (!drained) {
+ Logger.w {
+ "$label transport close timed out after $operationDrainTimeout while draining admitted operations"
+ }
+ }
+ return drained
+ }
+
+ private suspend fun runTeardown(teardown: suspend () -> Unit): Boolean {
+ val tornDown =
+ withTimeoutOrNull(teardownTimeout) {
+ teardown()
+ true
+ } == true
+ if (!tornDown) Logger.w { "$label transport teardown timed out after $teardownTimeout" }
+ return tornDown
+ }
+
+ private sealed interface ClosePreparation {
+ data object Completed : ClosePreparation
+
+ data object TimedOut : ClosePreparation
+
+ data class Failed(val failure: Throwable) : ClosePreparation
+ }
+
+ private data class ClosePlan(
+ val owner: Boolean,
+ val completion: CompletableDeferred<Boolean>,
+ val operationDrain: CompletableDeferred<Unit>?,
+ )
+
+ private fun releaseOperation() {
+ val waiter =
+ synchronized(lock) {
+ if (admittedOperations <= 0) {
+ Logger.e { "$label transport lifecycle operation count underflow" }
+ operationDrainWaiter.also { operationDrainWaiter = null }
+ } else {
+ admittedOperations--
+ if (admittedOperations == 0) {
+ operationDrainWaiter.also { operationDrainWaiter = null }
+ } else {
+ null
+ }
+ }
+ }
+ waiter?.complete(Unit)
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt
index 045d3b7ec8..eabf626e5f 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/HeartbeatSender.kt
@@ -17,6 +17,8 @@
package org.meshtastic.core.network.transport
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import kotlin.concurrent.atomics.AtomicInt
@@ -29,29 +31,42 @@ import kotlin.concurrent.atomics.ExperimentalAtomicApi
* expiring. Each call uses a monotonically increasing nonce to prevent the firmware's per-connection duplicate-write
* filter from silently dropping it.
*
- * @param sendToRadio callback to transmit the encoded heartbeat bytes to the radio
+ * @param sendToRadio callback that reports whether the transport accepted the encoded heartbeat bytes
* @param afterHeartbeat optional suspend callback invoked after sending (e.g. to schedule a drain)
* @param logTag tag for log messages
*/
class HeartbeatSender(
- private val sendToRadio: (ByteArray) -> Unit,
+ private val sendToRadio: (ByteArray) -> Boolean,
private val afterHeartbeat: (suspend () -> Unit)? = null,
private val logTag: String = "HeartbeatSender",
) {
@OptIn(ExperimentalAtomicApi::class)
private val nonce = AtomicInt(0)
+ private val nonceMutex = Mutex()
/**
* Sends a heartbeat to the radio.
*
* The firmware responds to heartbeats by queuing a `queueStatus` FromRadio packet, proving the link is alive and
* keeping the local node's lastHeard timestamp current.
+ *
+ * @return `true` when the transport accepted the heartbeat handoff.
*/
@OptIn(ExperimentalAtomicApi::class)
- suspend fun sendHeartbeat() {
- val n = nonce.fetchAndAdd(1)
- Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" }
- sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode())
+ suspend fun sendHeartbeat(): Boolean {
+ val accepted =
+ nonceMutex.withLock {
+ val n = nonce.load()
+ Logger.v { "[$logTag] Sending ToRadio heartbeat (nonce=$n)" }
+ val admitted = sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)).encode())
+ if (admitted) nonce.fetchAndAdd(1)
+ admitted
+ }
+ if (!accepted) {
+ Logger.w { "[$logTag] Heartbeat handoff was rejected by the transport" }
+ return false
+ }
afterHeartbeat?.invoke()
+ return true
}
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt
index cad25a487d..133bc89af6 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportReconnectCrashTest.kt
@@ -332,6 +332,62 @@ class BleRadioTransportReconnectCrashTest {
}
}
+ @Test
+ fun `retired BLE profile cannot dispatch inbound packets after reconnect`() = runTest {
+ val device = FakeBleDevice(address = address, name = "Test Radio")
+ bluetoothRepository.bond(device)
+ scanner.emitDevice(device)
+ val retiredService = connection.service
+ val replacementService = FakeBleService()
+ listOf(retiredService, replacementService).forEach { bleService ->
+ bleService.addCharacteristic(FROMNUM_CHARACTERISTIC)
+ bleService.addCharacteristic(FROMRADIO_CHARACTERISTIC)
+ }
+ connection.profileServiceProvider = { call -> if (call == 1) retiredService else replacementService }
+ var dispatchedPackets = 0
+ every { service.handleFromRadio(any()) } calls { dispatchedPackets++ }
+
+ val bleTransport =
+ BleRadioTransport(
+ scope = this,
+ scanner = scanner,
+ bluetoothRepository = bluetoothRepository,
+ connectionFactory = connectionFactory,
+ callback = service,
+ address = address,
+ )
+ try {
+ bleTransport.start()
+ advanceTimeBy(4_000L)
+ // Virtual time lets profile setup settle; stability is based on wall-clock uptime instead.
+ advanceTimeBy(6_000L)
+
+ connection.simulateRemoteDisconnect(reason = DisconnectReason.Timeout)
+ testScheduler.runCurrent()
+ // Connection uptime is wall-clock time (nowMillis), so a slow host can classify this disconnect as
+ // stable instead of unstable. Budget enough virtual time to cover either retry path before asserting.
+ advanceTimeBy(30_000L)
+ assertTrue(
+ connection.profileCalls >= 2,
+ "Reconnect must publish a replacement BLE profile (actual: ${connection.profileCalls})",
+ )
+
+ // FakeBleConnection intentionally leaves the retired profile collector active. Drive each profile's
+ // service independently so shared queue draining cannot decide which generation consumes the packet.
+ retiredService.enqueueRead(FROMRADIO_CHARACTERISTIC, byteArrayOf(1, 2, 3))
+ retiredService.emitNotification(FROMNUM_CHARACTERISTIC, byteArrayOf(1))
+ testScheduler.runCurrent()
+ assertEquals(0, dispatchedPackets, "The retired BLE profile must not dispatch a late inbound packet")
+
+ replacementService.enqueueRead(FROMRADIO_CHARACTERISTIC, byteArrayOf(4, 5, 6))
+ replacementService.emitNotification(FROMNUM_CHARACTERISTIC, byteArrayOf(1))
+ testScheduler.runCurrent()
+ assertEquals(1, dispatchedPackets, "The replacement BLE profile must still dispatch inbound packets")
+ } finally {
+ bleTransport.close()
+ }
+ }
+
// ─── Session-failure recovery ────────────────────────────────────────────────────────────────
@Test
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
index 4dacc812c5..b690ab9762 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/BleRadioTransportTest.kt
@@ -24,10 +24,14 @@ import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeout
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
@@ -47,6 +51,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class BleRadioTransportTest {
@@ -104,6 +109,90 @@ class BleRadioTransportTest {
assertEquals(address, bleTransport.address)
}
+ @Test
+ fun `send is rejected before the BLE profile is available`() = runTest {
+ val bleTransport =
+ BleRadioTransport(
+ scope = backgroundScope,
+ scanner = scanner,
+ bluetoothRepository = bluetoothRepository,
+ connectionFactory = connectionFactory,
+ callback = service,
+ address = address,
+ )
+
+ try {
+ assertFalse(bleTransport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+ } finally {
+ bleTransport.close()
+ }
+ }
+
+ @Test
+ fun `close drains every write admitted by the active BLE profile generation`() = runTest {
+ val device = FakeBleDevice(address = address, name = "Test Device")
+ bluetoothRepository.bond(device)
+ scanner.emitDevice(device)
+ val firstWriteStarted = CompletableDeferred<Unit>()
+ val releaseFirstWrite = CompletableDeferred<Unit>()
+ val secondWriteStarted = CompletableDeferred<Unit>()
+ val releaseSecondWrite = CompletableDeferred<Unit>()
+ val firstPayload = byteArrayOf(1, 2, 3)
+ val secondPayload = byteArrayOf(4, 5, 6)
+ val bleTransport =
+ BleRadioTransport(
+ scope = this,
+ scanner = scanner,
+ bluetoothRepository = bluetoothRepository,
+ connectionFactory = connectionFactory,
+ callback = service,
+ address = address,
+ )
+
+ try {
+ bleTransport.start()
+ advanceTimeBy(4_000L)
+ connection.service.beforeWrite = { _, data ->
+ when {
+ data.contentEquals(firstPayload) -> {
+ firstWriteStarted.complete(Unit)
+ releaseFirstWrite.await()
+ }
+
+ data.contentEquals(secondPayload) -> {
+ secondWriteStarted.complete(Unit)
+ releaseSecondWrite.await()
+ }
+ }
+ }
+
+ assertTrue(bleTransport.handleSendToRadio(firstPayload))
+ runCurrent()
+ withTimeout(5.seconds) { firstWriteStarted.await() }
+ assertTrue(bleTransport.handleSendToRadio(secondPayload))
+ runCurrent()
+
+ val closeJob = async { bleTransport.close() }
+ runCurrent()
+ assertFalse(closeJob.isCompleted, "close must drain admitted BLE writes before GATT teardown")
+ assertEquals(0, connection.disconnectCalls, "GATT teardown must not overtake an admitted write")
+
+ releaseFirstWrite.complete(Unit)
+ runCurrent()
+ withTimeout(5.seconds) { secondWriteStarted.await() }
+ assertEquals(0, connection.disconnectCalls, "queued admitted work must run before GATT teardown")
+ assertFalse(closeJob.isCompleted, "close must wait until the second admitted write finishes")
+ releaseSecondWrite.complete(Unit)
+ withTimeout(5.seconds) { closeJob.await() }
+ assertEquals(1, connection.disconnectCalls)
+ } finally {
+ releaseFirstWrite.complete(Unit)
+ releaseSecondWrite.complete(Unit)
+ connection.service.beforeWrite = null
+ bleTransport.close()
+ }
+ }
+
/**
* After [BleReconnectPolicy.DEFAULT_FAILURE_THRESHOLD] consecutive connection failures,
* [RadioInterfaceService.onDisconnect] must be called so the higher layers can react (e.g. start the device-sleep
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
index d42912677a..1b1277c6f9 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/MockRadioTransportTest.kt
@@ -20,11 +20,15 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.encodeUtf8
+import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.RadioTransportCallback
import org.meshtastic.core.repository.TransportDisconnectReason
+import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.HardwareModel
@@ -34,6 +38,7 @@ import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -332,6 +337,120 @@ class MockRadioTransportTest {
}
}
+ @Test
+ fun `an open transport delivers the delayed fake acknowledgement`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback = callback, scope = scope, address = "mock")
+ val outbound = ToRadio(packet = MeshPacket(id = 77, from = 1234, want_ack = true)).encode()
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(outbound))
+ advanceTimeBy(ACK_WINDOW_MS)
+ runCurrent()
+
+ assertTrue(
+ callback.received.any { it.packet?.decoded?.request_id == 77 },
+ "an open mock transport must deliver the fake ACK",
+ )
+ transport.close()
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `close is terminal and suppresses delayed acknowledgements`() = runTest {
+ val callback = RecordingCallback()
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback = callback, scope = scope, address = "mock")
+ val outbound = ToRadio(packet = MeshPacket(id = 77, from = 1234, want_ack = true)).encode()
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(outbound))
+ transport.close()
+ advanceTimeBy(ACK_WINDOW_MS)
+ runCurrent()
+
+ assertFalse(
+ callback.received.any { it.packet?.decoded?.request_id == 77 },
+ "a delayed fake ACK must not escape after close",
+ )
+ assertFalse(transport.handleSendToRadio(outbound))
+ transport.start()
+ runCurrent()
+ assertEquals(1, callback.connects, "a closed mock transport must not reconnect")
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `malformed payload is rejected without escaping the admission contract`() = runTest {
+ val scope = transportScope()
+ try {
+ val transport = MockRadioTransport(callback = RecordingCallback(), scope = scope, address = "mock")
+
+ transport.start()
+ assertFalse(transport.handleSendToRadio(byteArrayOf(0x80.toByte())))
+
+ transport.close()
+ } finally {
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun `malformed admin payload is ignored without escaping the admission contract`() = runTest {
+ val scope = transportScope()
+ try {
+ val callback = RecordingCallback()
+ val transport = MockRadioTransport(callback = callback, scope = scope, address = "mock")
+ val outbound =
+ ToRadio(
+ packet =
+ MeshPacket(
+ id = 77,
+ decoded =
+ Data(portnum = PortNum.ADMIN_APP, payload = byteArrayOf(0x80.toByte()).toByteString()),
+ ),
+ )
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(outbound.encode()))
+ advanceTimeBy(ACK_WINDOW_MS)
+ runCurrent()
+ assertFalse(
+ callback.received.any { it.packet?.decoded?.request_id == 77 },
+ "a malformed admin payload must not produce a simulated response",
+ )
+
+ val validRequestId = 78
+ val validAdmin = AdminMessage(get_config_request = AdminMessage.ConfigType.LORA_CONFIG)
+ val validOutbound =
+ ToRadio(
+ packet =
+ MeshPacket(
+ id = validRequestId,
+ decoded = Data(portnum = PortNum.ADMIN_APP, payload = validAdmin.encode().toByteString()),
+ ),
+ )
+ assertTrue(transport.handleSendToRadio(validOutbound.encode()))
+ advanceTimeBy(ACK_WINDOW_MS)
+ runCurrent()
+ assertTrue(
+ callback.received.any { it.packet?.decoded?.request_id == validRequestId },
+ "a valid admin payload must still produce a simulated response",
+ )
+
+ transport.close()
+ } finally {
+ scope.cancel()
+ }
+ }
+
private companion object {
const val BROADCAST_ADDR = -1
const val MIN_DEMO_NODES = 8
@@ -342,5 +461,6 @@ class MockRadioTransportTest {
/** Mirrors `MockRadioTransport.LIVE_TICK_MS`, which is private to the transport. */
const val LIVE_TICK_MS = 20_000L
const val LIVE_TICKS_AFTER_CLOSE = 5
+ const val ACK_WINDOW_MS = 3_000L
}
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayFuzzTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayFuzzTest.kt
index 87d4ab5b33..55cb92829b 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayFuzzTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayFuzzTest.kt
@@ -70,7 +70,9 @@ class ReplayFuzzTest {
ReplayFuzz.forSeeds { random, seed ->
val input = if (seed % 2 == 0) ReplayFuzz.mutate(random, validAsset()) else ReplayFuzz.randomBytes(random)
val error =
- runCatching { ReplayRadioTransport(Sink(), this, address = "", frames = input, packetDelayMs = 0) }
+ runCatching {
+ ReplayRadioTransport(Sink(), backgroundScope, address = "", frames = input, packetDelayMs = 0)
+ }
.exceptionOrNull()
assertTrue(
error == null || error is IllegalArgumentException,
@@ -82,17 +84,23 @@ class ReplayFuzzTest {
/** The transport must absorb any outbound bytes — undecodable [ToRadio] is dropped, not thrown. */
@Test
fun `handleSendToRadio tolerates arbitrary outbound bytes`() = runTest {
- val transport = ReplayRadioTransport(Sink(), this, address = "", frames = validAsset(), packetDelayMs = 0)
- ReplayFuzz.forSeeds { random, seed ->
- val error =
- runCatching {
- transport.handleSendToRadio(ReplayFuzz.mutate(random, ToRadio(want_config_id = 1).encode()))
- transport.handleSendToRadio(ReplayFuzz.randomBytes(random))
- }
- .exceptionOrNull()
- assertTrue(error == null, "seed=$seed: handleSendToRadio threw $error")
+ val transport =
+ ReplayRadioTransport(Sink(), backgroundScope, address = "", frames = validAsset(), packetDelayMs = 0)
+ transport.start()
+ try {
+ ReplayFuzz.forSeeds { random, seed ->
+ val error =
+ runCatching {
+ transport.handleSendToRadio(ReplayFuzz.mutate(random, ToRadio(want_config_id = 1).encode()))
+ transport.handleSendToRadio(ReplayFuzz.randomBytes(random))
+ }
+ .exceptionOrNull()
+ assertTrue(error == null, "seed=$seed: handleSendToRadio threw $error")
+ }
+ testScheduler.runCurrent()
+ } finally {
+ transport.close()
}
- testScheduler.advanceUntilIdle()
}
/**
@@ -117,17 +125,21 @@ class ReplayFuzzTest {
val transport =
ReplayRadioTransport(
callback = sink,
- scope = this,
+ scope = backgroundScope,
address = "",
frames = ReplayFuzz.asset(sampleConfig, nodes, packets),
packetDelayMs = 0,
)
- transport.start()
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
- testScheduler.advanceUntilIdle()
+ try {
+ transport.start()
+ transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
+ testScheduler.runCurrent()
- assertTrue(sink.frames.isNotEmpty())
- sink.frames.forEach { FromRadio.ADAPTER.decode(it) } // round-trip held under hostile field values
+ assertTrue(sink.frames.isNotEmpty())
+ sink.frames.forEach { FromRadio.ADAPTER.decode(it) } // round-trip held under hostile field values
+ } finally {
+ transport.close()
+ }
}
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransportTest.kt
index 7871c81c8a..feb0287aba 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/ReplayRadioTransportTest.kt
@@ -29,6 +29,7 @@ import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ReplayRadioTransportTest {
@@ -78,8 +79,11 @@ class ReplayRadioTransportTest {
@Test
fun `start signals onConnect without emitting frames`() = runTest {
val callback = RecordingCallback()
- val transport = ReplayRadioTransport(callback, this, address = "", frames = asset(), packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
+ assertEquals(0, callback.connects, "construction must not publish lifecycle callbacks")
+ assertFalse(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode()))
transport.start()
assertEquals(1, callback.connects)
@@ -89,51 +93,125 @@ class ReplayRadioTransportTest {
@Test
fun `config nonce is answered with config frames and the echoed nonce only`() = runTest {
val callback = RecordingCallback()
- val transport = ReplayRadioTransport(callback, this, address = "", frames = asset(), packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
- testScheduler.advanceUntilIdle()
+ transport.start()
+ val accepted = transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode())
+ testScheduler.runCurrent()
+ assertTrue(accepted)
assertEquals(configFrames + FromRadio(config_complete_id = HandshakeConstants.CONFIG_NONCE), callback.received)
}
@Test
fun `node nonce is answered with the node db then the packet stream`() = runTest {
val callback = RecordingCallback()
- val transport = ReplayRadioTransport(callback, this, address = "", frames = asset(), packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
- testScheduler.advanceUntilIdle()
+ transport.start()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
val expected = nodeFrames + FromRadio(config_complete_id = HandshakeConstants.NODE_INFO_NONCE) + packetFrames
assertEquals(expected, callback.received)
}
+ @Test
+ fun `back to back handshake requests are replayed sequentially`() = runTest {
+ val callback = RecordingCallback()
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode()))
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
+
+ val expected =
+ configFrames +
+ FromRadio(config_complete_id = HandshakeConstants.CONFIG_NONCE) +
+ nodeFrames +
+ FromRadio(config_complete_id = HandshakeConstants.NODE_INFO_NONCE) +
+ packetFrames
+ assertEquals(expected, callback.received)
+ }
+
@Test
fun `a second node nonce does not restart the packet stream`() = runTest {
val callback = RecordingCallback()
- val transport = ReplayRadioTransport(callback, this, address = "", frames = asset(), packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
- testScheduler.advanceUntilIdle()
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
- testScheduler.advanceUntilIdle()
+ transport.start()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
val packetsSent = callback.received.count { it.packet != null }
assertEquals(packetFrames.size, packetsSent)
}
+ @Test
+ fun `handshake worker remains responsive while packet replay is streaming`() = runTest {
+ val callback = RecordingCallback()
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 1_000)
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode()))
+ testScheduler.runCurrent()
+
+ assertTrue(
+ callback.received.any { it.config_complete_id == HandshakeConstants.CONFIG_NONCE },
+ "a repeated config handshake must not wait for the long packet replay to finish",
+ )
+ transport.close()
+ }
+
@Test
fun `non-handshake traffic is ignored`() = runTest {
val callback = RecordingCallback()
- val transport = ReplayRadioTransport(callback, this, address = "", frames = asset(), packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 0)
- transport.handleSendToRadio(ToRadio(packet = MeshPacket(id = 99)).encode())
- testScheduler.advanceUntilIdle()
+ transport.start()
+ val accepted = transport.handleSendToRadio(ToRadio(packet = MeshPacket(id = 99)).encode())
+ testScheduler.runCurrent()
+ assertTrue(accepted, "a live replay transport accepts ordinary traffic before intentionally discarding it")
assertTrue(callback.received.isEmpty())
}
+ @Test
+ fun `close cancels replay work and rejects later handshakes`() = runTest {
+ val callback = RecordingCallback()
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = asset(), packetDelayMs = 1_000)
+
+ transport.start()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
+ val receivedBeforeClose = callback.received.toList()
+ assertTrue(
+ receivedBeforeClose.isNotEmpty(),
+ "the node section must replay before close so the cancellation assertion is meaningful",
+ )
+
+ transport.close()
+ testScheduler.advanceTimeBy(5_000)
+ testScheduler.runCurrent()
+
+ assertEquals(receivedBeforeClose, callback.received, "close must cancel the in-flight packet stream")
+ assertFalse(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE).encode()))
+ transport.start()
+ assertEquals(1, callback.connects, "a closed replay transport must not reconnect")
+ }
+
// ── Malformed-asset handling: the parser must fail fast with a clear error, never underflow or over-allocate. ──
/** Builds a raw asset body so we can craft deliberately-corrupt inputs the [asset] helper cannot. */
@@ -143,7 +221,7 @@ class ReplayRadioTransportTest {
fun `truncated section count is rejected`() = runTest {
// Only two bytes — not enough for the leading u32 config count.
assertFailsWith<IllegalArgumentException> {
- ReplayRadioTransport(RecordingCallback(), this, address = "", frames = byteArrayOf(0x00, 0x01))
+ ReplayRadioTransport(RecordingCallback(), backgroundScope, address = "", frames = byteArrayOf(0x00, 0x01))
}
}
@@ -155,7 +233,7 @@ class ReplayRadioTransportTest {
write(byteArrayOf(1, 2, 3)) // …but only 3 follow.
}
assertFailsWith<IllegalArgumentException> {
- ReplayRadioTransport(RecordingCallback(), this, address = "", frames = frames)
+ ReplayRadioTransport(RecordingCallback(), backgroundScope, address = "", frames = frames)
}
}
@@ -167,7 +245,7 @@ class ReplayRadioTransportTest {
write(byteArrayOf(1, 2)) // …but provides only 1, then EOF.
}
assertFailsWith<IllegalArgumentException> {
- ReplayRadioTransport(RecordingCallback(), this, address = "", frames = frames)
+ ReplayRadioTransport(RecordingCallback(), backgroundScope, address = "", frames = frames)
}
}
@@ -179,11 +257,12 @@ class ReplayRadioTransportTest {
writeInt(0) // 0 node frames
// no packet bytes
}
- val transport = ReplayRadioTransport(callback, this, address = "", frames = frames, packetDelayMs = 0)
+ val transport =
+ ReplayRadioTransport(callback, backgroundScope, address = "", frames = frames, packetDelayMs = 0)
transport.start()
- transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode())
- testScheduler.advanceUntilIdle()
+ assertTrue(transport.handleSendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE).encode()))
+ testScheduler.runCurrent()
// Only the injected config_complete — no nodes, no packets — proves zero-length sections parse cleanly.
assertEquals(listOf(FromRadio(config_complete_id = HandshakeConstants.NODE_INFO_NONCE)), callback.received)
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt
index 15f381f3fd..a5fade7f2e 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/StreamTransportTest.kt
@@ -26,11 +26,19 @@ import io.kotest.property.arbitrary.byte
import io.kotest.property.arbitrary.byteArray
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioTransportCallback
import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
class StreamTransportTest {
@@ -38,7 +46,8 @@ class StreamTransportTest {
private val callback: RadioTransportCallback = mock(MockMode.autofill)
private lateinit var fakeStream: FakeStreamTransport
- class FakeStreamTransport(callback: RadioTransportCallback, scope: TestScope) : StreamTransport(callback, scope) {
+ class FakeStreamTransport(callback: RadioTransportCallback, scope: CoroutineScope) :
+ StreamTransport(callback, scope) {
val sentBytes = mutableListOf<ByteArray>()
override fun sendBytes(p: ByteArray) {
@@ -55,21 +64,197 @@ class StreamTransportTest {
fun feed(b: Byte) = readChar(b)
+ fun queue(payload: ByteArray, writer: suspend (ByteArray) -> Unit, onCompletion: () -> Unit): Boolean =
+ queueFramedSend(payload, writer = writer, onCompletion = onCompletion)
+
public override fun connect() = super.connect()
}
- private val testScope = TestScope()
-
@Test
fun `handleSendToRadio property test`() = runTest {
- fakeStream = FakeStreamTransport(callback, testScope)
+ fakeStream = FakeStreamTransport(callback, backgroundScope)
+
+ checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload ->
+ assertTrue(fakeStream.handleSendToRadio(payload))
+ testScheduler.runCurrent()
+ }
+ }
+
+ @Test
+ fun `send is rejected after the transport scope stops`() {
+ val stoppedScope = TestScope()
+ val transport = FakeStreamTransport(callback, stoppedScope)
+ stoppedScope.cancel()
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+ assertTrue(transport.sentBytes.isEmpty())
+ }
+
+ @Test
+ fun `rejected framed send releases its completion owner exactly once`() {
+ val stoppedScope = TestScope()
+ val transport = FakeStreamTransport(callback, stoppedScope)
+ var completions = 0
+ stoppedScope.cancel()
+
+ val accepted = transport.queue(byteArrayOf(1), writer = {}, onCompletion = { completions++ })
+
+ assertFalse(accepted)
+ assertEquals(1, completions)
+ }
+
+ @Test
+ fun `concurrent framed sends preserve FIFO without interleaving writes`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ val firstWriteStarted = CompletableDeferred<Unit>()
+ val releaseFirstWrite = CompletableDeferred<Unit>()
+ val events = mutableListOf<String>()
+ var firstWrites = 0
+ var completions = 0
+
+ assertTrue(
+ transport.queue(
+ payload = byteArrayOf(1),
+ writer = {
+ events += "first"
+ if (firstWrites++ == 0) {
+ firstWriteStarted.complete(Unit)
+ releaseFirstWrite.await()
+ }
+ },
+ onCompletion = { completions++ },
+ ),
+ )
+ assertTrue(transport.queue(payload = byteArrayOf(2), writer = { events += "second" }) { completions++ })
+
+ firstWriteStarted.await()
+ assertEquals(listOf("first"), events)
+ releaseFirstWrite.complete(Unit)
+ testScheduler.runCurrent()
+
+ // StreamFrameCodec invokes the writer once for the frame header and once for the body of each payload.
+ assertEquals(listOf("first", "first", "second", "second"), events)
+ assertEquals(2, completions)
+ }
+
+ @Test
+ fun `framed send queue reports backpressure at its capacity`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ val firstWriteStarted = CompletableDeferred<Unit>()
+ val releaseFirstWrite = CompletableDeferred<Unit>()
+ var acceptedCompletions = 0
+ var rejectedCompletions = 0
+
+ assertTrue(
+ transport.queue(
+ payload = byteArrayOf(0),
+ writer = {
+ firstWriteStarted.complete(Unit)
+ releaseFirstWrite.await()
+ },
+ onCompletion = {},
+ ),
+ )
+ firstWriteStarted.await()
+
+ val completionsByIndex = IntArray(StreamTransport.MAX_PENDING_SENDS + 1)
+ // The worker has dequeued the blocked first send, so the bounded channel has exactly MAX_PENDING_SENDS slots.
+ val queuedAdmissions =
+ List(StreamTransport.MAX_PENDING_SENDS + 1) { index ->
+ transport.queue(byteArrayOf(index.toByte()), writer = {}) { completionsByIndex[index]++ }
+ }
+ queuedAdmissions.forEachIndexed { index, accepted ->
+ if (accepted) {
+ acceptedCompletions += completionsByIndex[index]
+ } else {
+ rejectedCompletions += completionsByIndex[index]
+ }
+ }
+
+ assertEquals(StreamTransport.MAX_PENDING_SENDS, queuedAdmissions.count { it })
+ assertFalse(queuedAdmissions.last(), "the first send beyond capacity must be rejected")
+ assertEquals(1, rejectedCompletions, "a capacity-rejected send must release its owner exactly once")
+ assertEquals(0, acceptedCompletions, "admitted sends must stay queued while the worker is blocked")
+ releaseFirstWrite.complete(Unit)
+ testScheduler.runCurrent()
+
+ val admittedCompletions =
+ queuedAdmissions
+ .withIndex()
+ .filter { (_, accepted) -> accepted }
+ .sumOf { (index, _) -> completionsByIndex[index] }
+ assertEquals(StreamTransport.MAX_PENDING_SENDS, admittedCompletions)
+ assertEquals(1, completionsByIndex.last(), "the rejected send must release its owner exactly once")
+ }
+
+ @Test
+ fun `close lets an active framed send finish before returning`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ val writeStarted = CompletableDeferred<Unit>()
+ val releaseWrite = CompletableDeferred<Unit>()
+ var completions = 0
+
+ assertTrue(
+ transport.queue(
+ payload = byteArrayOf(1),
+ writer = {
+ writeStarted.complete(Unit)
+ releaseWrite.await()
+ },
+ onCompletion = { completions++ },
+ ),
+ )
+ writeStarted.await()
+
+ val closeJob = async { transport.close() }
+ testScheduler.runCurrent()
+
+ assertFalse(closeJob.isCompleted, "close must not cancel an active frame while it can still drain")
+ releaseWrite.complete(Unit)
+ closeJob.await()
+
+ assertEquals(1, completions)
+ }
+
+ @Test
+ fun `close completes ownership when the worker never started`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ var completions = 0
+
+ assertTrue(transport.queue(byteArrayOf(1), writer = {}, onCompletion = { completions++ }))
+ assertTrue(transport.queue(byteArrayOf(2), writer = {}, onCompletion = { completions++ }))
- checkAll(Arb.byteArray(Arb.int(0, 512), Arb.byte())) { payload -> fakeStream.handleSendToRadio(payload) }
+ transport.close()
+
+ assertEquals(2, completions, "shutdown must release owners even before the worker starts")
+ }
+
+ @Test
+ fun `per-send cancellation does not stop the framed-send worker`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ var completions = 0
+ val laterWrites = mutableListOf<ByteArray>()
+
+ assertTrue(
+ transport.queue(
+ payload = byteArrayOf(1),
+ writer = { throw CancellationException("cancel this send only") },
+ onCompletion = { completions++ },
+ ),
+ )
+ assertTrue(
+ transport.queue(payload = byteArrayOf(2), writer = { laterWrites += it }, onCompletion = { completions++ }),
+ )
+
+ testScheduler.runCurrent()
+
+ assertTrue(laterWrites.isNotEmpty(), "a cancelled send must not permanently stop the worker")
+ assertEquals(2, completions)
}
@Test
fun `readChar property test`() = runTest {
- fakeStream = FakeStreamTransport(callback, testScope)
+ fakeStream = FakeStreamTransport(callback, backgroundScope)
checkAll(Arb.byteArray(Arb.int(0, 100), Arb.byte())) { data ->
data.forEach { fakeStream.feed(it) }
@@ -78,21 +263,49 @@ class StreamTransportTest {
}
@Test
- fun `connect sends wake bytes`() {
- fakeStream = FakeStreamTransport(callback, testScope)
+ fun `connect sends wake bytes`() = runTest {
+ fakeStream = FakeStreamTransport(callback, backgroundScope)
fakeStream.connect()
assertTrue(fakeStream.sentBytes.isNotEmpty())
assertTrue(fakeStream.sentBytes[0].contentEquals(StreamFrameCodec.WAKE_BYTES))
verify { callback.onConnect() }
+ fakeStream.close()
}
@Test
fun `close does not emit disconnect callback`() = runTest {
- fakeStream = FakeStreamTransport(callback, testScope)
+ fakeStream = FakeStreamTransport(callback, backgroundScope)
+ fakeStream.connect()
fakeStream.close()
- verify(mode = VerifyMode.not) { callback.onDisconnect(isPermanent = any(), errorMessage = any()) }
+ verify(mode = VerifyMode.not) {
+ callback.onDisconnect(isPermanent = any(), errorMessage = any(), reason = any())
+ }
+ }
+
+ @Test
+ fun `close completes ownership for in-flight and queued framed sends`() = runTest {
+ val transport = FakeStreamTransport(callback, backgroundScope)
+ val firstWriteStarted = CompletableDeferred<Unit>()
+ var completions = 0
+
+ assertTrue(
+ transport.queue(
+ payload = byteArrayOf(1),
+ writer = {
+ firstWriteStarted.complete(Unit)
+ awaitCancellation()
+ },
+ onCompletion = { completions++ },
+ ),
+ )
+ assertTrue(transport.queue(byteArrayOf(2), writer = {}, onCompletion = { completions++ }))
+ firstWriteStarted.await()
+
+ transport.close()
+
+ assertEquals(2, completions, "shutdown must release every accepted framed-send owner exactly once")
}
}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TcpRadioTransportTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TcpRadioTransportTest.kt
new file mode 100644
index 0000000000..b637e836ef
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TcpRadioTransportTest.kt
@@ -0,0 +1,286 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import dev.mokkery.MockMode
+import dev.mokkery.mock
+import kotlinx.atomicfu.atomic
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.async
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.withTimeoutOrNull
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.network.transport.TcpTransport
+import org.meshtastic.core.repository.RadioTransportCallback
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+class TcpRadioTransportTest {
+
+ private val callback: RadioTransportCallback = mock(MockMode.autofill)
+
+ private class FakeTcpRadioConnection : TcpRadioConnection {
+ private val connectedState = atomic(false)
+ var connected: Boolean
+ get() = connectedState.value
+ set(value) {
+ connectedState.value = value
+ }
+
+ private val sentPacketsLock = SynchronizedObject()
+ private val mutableSentPackets = mutableListOf<ByteArray>()
+ private val startsState = atomic(0)
+ private val stopsState = atomic(0)
+
+ val sentPackets: List<ByteArray>
+ get() = synchronized(sentPacketsLock) { mutableSentPackets.toList() }
+
+ val starts: Int
+ get() = startsState.value
+
+ val stops: Int
+ get() = stopsState.value
+
+ var stopStarted: CompletableDeferred<Unit>? = null
+ var releaseStop: CompletableDeferred<Unit>? = null
+ var sendStarted: CompletableDeferred<Unit>? = null
+ var releaseSend: CompletableDeferred<Unit>? = null
+ private val sendInFlight = atomic(false)
+ private val stopObservedSendInFlightState = atomic(false)
+
+ val stopObservedSendInFlight: Boolean
+ get() = stopObservedSendInFlightState.value
+
+ override val isConnected: Boolean
+ get() = connected
+
+ override fun start(address: String) {
+ startsState.incrementAndGet()
+ }
+
+ override fun stop() {
+ stopsState.incrementAndGet()
+ stopObservedSendInFlightState.value = sendInFlight.value
+ stopStarted?.complete(Unit)
+ releaseStop?.let { gate -> runBlocking { withTimeout(5.seconds) { gate.await() } } }
+ connected = false
+ }
+
+ override suspend fun sendPacket(payload: ByteArray) {
+ sendInFlight.value = true
+ try {
+ sendStarted?.complete(Unit)
+ releaseSend?.await()
+ synchronized(sentPacketsLock) { mutableSentPackets += payload }
+ } finally {
+ sendInFlight.value = false
+ }
+ }
+
+ override suspend fun sendHeartbeat() = Unit
+ }
+
+ private fun createTransport(scope: CoroutineScope, connection: FakeTcpRadioConnection): TcpRadioTransport =
+ TcpRadioTransport(
+ callback = callback,
+ scope = scope,
+ address = "127.0.0.1",
+ connectionFactory = { _: TcpTransport.Listener -> connection },
+ )
+
+ @Test
+ fun `send is rejected while the transport was never started`() = runTest {
+ val dispatcher = StandardTestDispatcher(testScheduler)
+ val dispatchers = CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher)
+ val transport = TcpRadioTransport(callback, this, dispatchers, address = "127.0.0.1")
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+
+ transport.close()
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(4, 5, 6)))
+ }
+
+ @Test
+ fun `established transport admits send before close and rejects after close`() = runTest {
+ val connection = FakeTcpRadioConnection().apply { connected = true }
+ val transport = createTransport(this, connection)
+ val payload = byteArrayOf(1, 2, 3)
+
+ assertTrue(transport.handleSendToRadio(payload))
+ runCurrent()
+ assertEquals(listOf(payload.toList()), connection.sentPackets.map(ByteArray::toList))
+
+ transport.close()
+ transport.close()
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(4, 5, 6)))
+ assertEquals(1, connection.stops)
+ }
+
+ @Test
+ fun `close waits for an admitted send to finish before stopping the connection`() = runTest {
+ val payload = byteArrayOf(4, 5, 6)
+ val sendStarted = CompletableDeferred<Unit>()
+ val releaseSend = CompletableDeferred<Unit>()
+ val stopStarted = CompletableDeferred<Unit>()
+ val connection =
+ FakeTcpRadioConnection().apply {
+ connected = true
+ this.sendStarted = sendStarted
+ this.releaseSend = releaseSend
+ this.stopStarted = stopStarted
+ }
+ val transportScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ val transport = createTransport(transportScope, connection)
+
+ try {
+ assertTrue(transport.handleSendToRadio(payload))
+ sendStarted.await()
+
+ val closeJob = async(Dispatchers.Default, start = CoroutineStart.UNDISPATCHED) { transport.close() }
+ val prematureStop =
+ withContext(Dispatchers.Default) { withTimeoutOrNull(200.milliseconds) { stopStarted.await() } }
+ assertNull(prematureStop, "close must not stop the connection while admitted TCP I/O is suspended")
+
+ releaseSend.complete(Unit)
+ withContext(Dispatchers.Default) { withTimeout(5.seconds) { stopStarted.await() } }
+
+ assertEquals(
+ listOf(payload.toList()),
+ connection.sentPackets.map(ByteArray::toList),
+ "the admitted send must finish before connection teardown starts",
+ )
+ assertFalse(connection.stopObservedSendInFlight)
+ closeJob.await()
+ assertEquals(1, connection.stops)
+ } finally {
+ releaseSend.complete(Unit)
+ transport.close()
+ transportScope.cancel()
+ }
+ }
+
+ @Test
+ fun `hung admitted send times out before close stops the connection`() = runTest {
+ val sendStarted = CompletableDeferred<Unit>()
+ val releaseSend = CompletableDeferred<Unit>()
+ val connection =
+ FakeTcpRadioConnection().apply {
+ connected = true
+ this.sendStarted = sendStarted
+ this.releaseSend = releaseSend
+ }
+ val transport = createTransport(this, connection)
+
+ try {
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+ runCurrent()
+ sendStarted.await()
+
+ val closeJob = async { transport.close() }
+ runCurrent()
+ advanceTimeBy(TcpRadioTransport.OPERATION_TIMEOUT.inWholeMilliseconds)
+ runCurrent()
+ closeJob.await()
+
+ assertEquals(1, connection.stops)
+ assertFalse(connection.stopObservedSendInFlight)
+ assertTrue(connection.sentPackets.isEmpty())
+ } finally {
+ releaseSend.complete(Unit)
+ }
+ }
+
+ @Test
+ fun `operation timeout stops a connection before another send can be admitted`() = runTest {
+ val sendStarted = CompletableDeferred<Unit>()
+ val releaseSend = CompletableDeferred<Unit>()
+ val connection =
+ FakeTcpRadioConnection().apply {
+ connected = true
+ this.sendStarted = sendStarted
+ this.releaseSend = releaseSend
+ }
+ val transport = createTransport(this, connection)
+
+ try {
+ transport.start()
+ assertEquals(1, connection.starts)
+ assertTrue(transport.handleSendToRadio(byteArrayOf(1, 2, 3)))
+ runCurrent()
+ sendStarted.await()
+
+ advanceTimeBy(TcpRadioTransport.OPERATION_TIMEOUT.inWholeMilliseconds)
+ runCurrent()
+
+ assertEquals(1, connection.stops)
+ transport.start()
+ assertEquals(1, connection.starts, "a stopped TCP transport must not be restarted in place")
+ assertFalse(transport.handleSendToRadio(byteArrayOf(4, 5, 6)))
+ } finally {
+ releaseSend.complete(Unit)
+ transport.close()
+ }
+ }
+
+ @Test
+ fun `close racing with send wins admission before teardown can return`() = runTest {
+ val stopStarted = CompletableDeferred<Unit>()
+ val releaseStop = CompletableDeferred<Unit>()
+ val connection =
+ FakeTcpRadioConnection().apply {
+ connected = true
+ this.stopStarted = stopStarted
+ this.releaseStop = releaseStop
+ }
+ val transport = createTransport(this, connection)
+
+ // Both close and send use real threads here: close blocks inside the fake stop callback while the second
+ // Default worker verifies that lifecycle admission has already closed.
+ val closeJob = async(Dispatchers.Default) { transport.close() }
+ try {
+ stopStarted.await()
+ val sendJob = async(Dispatchers.Default) { transport.handleSendToRadio(byteArrayOf(7, 8, 9)) }
+
+ assertFalse(sendJob.await(), "send must be rejected while the first close is still draining")
+ } finally {
+ releaseStop.complete(Unit)
+ }
+ closeJob.await()
+
+ assertTrue(connection.sentPackets.isEmpty())
+ }
+}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGateTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGateTest.kt
new file mode 100644
index 0000000000..d5d0f29f06
--- /dev/null
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/radio/TransportLifecycleGateTest.kt
@@ -0,0 +1,190 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.network.radio
+
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TransportLifecycleGateTest {
+
+ @Test
+ fun `close rejects new work drains admitted work and tears down exactly once`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ val operationLease = checkNotNull(gate.tryAcquire())
+ var teardowns = 0
+
+ val firstClose = async { gate.close { teardowns++ } }
+ runCurrent()
+ val secondClose = async { gate.close { teardowns++ } }
+ runCurrent()
+
+ assertTrue(gate.isClosed)
+ assertNull(gate.runIfOpen { Unit }, "close must reject operations before waiting for the admitted one")
+ assertNull(gate.tryAcquire(), "close must stop issuing operation leases")
+ assertFalse(firstClose.isCompleted)
+ assertFalse(secondClose.isCompleted)
+
+ operationLease.release()
+ runCurrent()
+ firstClose.await()
+ secondClose.await()
+
+ assertEquals(1, teardowns)
+ assertNull(gate.runIfOpen { Unit }, "completed close must remain terminal")
+ assertNull(gate.tryAcquire(), "completed close must remain terminal for leases")
+ }
+
+ @Test
+ fun `teardown failure leaves the gate terminal and is not retried`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ val expected = IllegalStateException("teardown failed")
+ var teardownAttempts = 0
+
+ val failure =
+ assertFailsWith<IllegalStateException> {
+ gate.close {
+ teardownAttempts++
+ throw expected
+ }
+ }
+
+ assertIs<IllegalStateException>(failure)
+ assertEquals(expected.message, failure.message)
+ assertTrue(gate.isClosed)
+
+ val repeatedFailure = assertFailsWith<IllegalStateException> { gate.close { teardownAttempts++ } }
+ assertEquals(expected.message, repeatedFailure.message)
+ assertEquals(1, teardownAttempts)
+ assertNull(gate.runIfOpen { Unit })
+ }
+
+ @Test
+ fun `throwing admitted block releases its operation lease`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ var teardowns = 0
+
+ assertFailsWith<IllegalStateException> { gate.runIfOpen { throw IllegalStateException("operation failed") } }
+ assertTrue(gate.close { teardowns++ }, "a released lease must let close drain without timing out")
+
+ assertEquals(1, teardowns)
+ }
+
+ @Test
+ fun `close bounds a leaked operation before teardown`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ val lease = checkNotNull(gate.tryAcquire())
+ var teardowns = 0
+
+ val closeJob = async { gate.close { teardowns++ } }
+ runCurrent()
+ assertFalse(closeJob.isCompleted)
+
+ advanceTimeBy(TransportLifecycleGate.OPERATION_DRAIN_TIMEOUT.inWholeMilliseconds)
+ runCurrent()
+ assertFalse(closeJob.await(), "a leaked admitted operation must be reported as an incomplete close")
+
+ assertEquals(1, teardowns)
+ lease.release()
+ }
+
+ @Test
+ fun `close bounds preparation separately before teardown`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ var teardowns = 0
+
+ val closeJob = async { gate.close(beforeDrain = { awaitCancellation() }, teardown = { teardowns++ }) }
+ runCurrent()
+ assertFalse(closeJob.isCompleted)
+
+ advanceTimeBy(TransportLifecycleGate.OPERATION_DRAIN_TIMEOUT.inWholeMilliseconds)
+ runCurrent()
+
+ assertFalse(closeJob.await(), "a timed-out preparation must report an incomplete close")
+ assertEquals(1, teardowns, "teardown must still run after preparation times out")
+ }
+
+ @Test
+ fun `close reports a throwing preparation without skipping teardown`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ val expected = IllegalStateException("preparation failed")
+ var teardowns = 0
+
+ val failure =
+ assertFailsWith<IllegalStateException> {
+ gate.close(beforeDrain = { throw expected }, teardown = { teardowns++ })
+ }
+
+ assertEquals(expected.message, failure.message)
+ assertEquals(1, teardowns, "teardown must still release the resource after preparation fails")
+ val repeated = assertFailsWith<IllegalStateException> { gate.close() }
+ assertEquals(expected.message, repeated.message, "concurrent or later closers must observe the shared failure")
+ }
+
+ @Test
+ fun `close bounds cooperative suspended teardown and releases every waiter`() = runTest {
+ val gate = TransportLifecycleGate("test")
+ var teardownCalls = 0
+
+ val first = async {
+ gate.close {
+ teardownCalls++
+ awaitCancellation()
+ }
+ }
+ testScheduler.runCurrent()
+ val second = async { gate.close() }
+ testScheduler.runCurrent()
+
+ testScheduler.advanceTimeBy(TransportLifecycleGate.TEARDOWN_TIMEOUT.inWholeMilliseconds)
+ testScheduler.runCurrent()
+ assertFalse(first.await(), "a timed-out teardown must be reported to the owning closer")
+ assertFalse(second.await(), "every concurrent closer must observe the same timed-out result")
+
+ assertEquals(1, teardownCalls)
+ assertTrue(gate.isClosed)
+ }
+
+ @Test
+ fun `releasing a lease twice does not unblock close early`() = runTest {
+ val gate = TransportLifecycleGate("Test")
+ val first = checkNotNull(gate.tryAcquire())
+ val second = checkNotNull(gate.tryAcquire())
+ var teardowns = 0
+
+ first.release()
+ first.release()
+
+ val closeJob = async { gate.close { teardowns++ } }
+ runCurrent()
+ assertFalse(closeJob.isCompleted, "a repeated release must not drop the second lease")
+
+ second.release()
+ runCurrent()
+ assertTrue(closeJob.await())
+ assertEquals(1, teardowns)
+ }
+}
diff --git a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/HeartbeatSenderTest.kt b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/HeartbeatSenderTest.kt
index cb25f1fbb3..11ef410c03 100644
--- a/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/HeartbeatSenderTest.kt
+++ b/core/network/src/commonTest/kotlin/org/meshtastic/core/network/transport/HeartbeatSenderTest.kt
@@ -27,8 +27,10 @@ import kotlinx.coroutines.test.runTest
import org.meshtastic.proto.ToRadio
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
+import kotlin.test.assertTrue
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -47,7 +49,7 @@ class HeartbeatSenderTest {
},
)
- sender.sendHeartbeat()
+ assertTrue(sender.sendHeartbeat())
assertEquals(1, sentPackets.size)
assertEquals(1, afterHeartbeatCalls)
@@ -58,6 +60,26 @@ class HeartbeatSenderTest {
assertNull(message.packet)
}
+ @Test
+ fun `rejected heartbeat skips post-send work`() = runTest {
+ val sentPackets = mutableListOf<ByteArray>()
+ var afterHeartbeatCalls = 0
+ var accept = false
+ val sender =
+ HeartbeatSender(
+ sendToRadio = { if (accept) sentPackets.add(it) else false },
+ afterHeartbeat = { afterHeartbeatCalls++ },
+ )
+
+ assertFalse(sender.sendHeartbeat())
+ assertEquals(0, afterHeartbeatCalls)
+
+ accept = true
+ assertTrue(sender.sendHeartbeat())
+ assertEquals(1, afterHeartbeatCalls)
+ assertHeartbeats(sentPackets, 0)
+ }
+
@Test
fun `heartbeat loop emits at the configured interval`() = runTest {
val sentPackets = mutableListOf<ByteArray>()
diff --git a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
index 45ba70eb73..d2fc834380 100644
--- a/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
+++ b/core/network/src/jvmMain/kotlin/org/meshtastic/core/network/SerialTransport.kt
@@ -19,23 +19,249 @@ package org.meshtastic.core.network
import co.touchlab.kermit.Logger
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortTimeoutException
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.network.radio.StreamTransport
+import org.meshtastic.core.network.radio.TransportLifecycleGate
import org.meshtastic.core.network.transport.HeartbeatSender
+import org.meshtastic.core.network.transport.StreamFrameCodec
import org.meshtastic.core.repository.RadioTransportCallback
import java.io.File
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration.Companion.seconds
+
+private const val SERIAL_WRITE_RETRY_DELAY_MS = 10L
+private const val NANOS_PER_MILLISECOND = 1_000_000L
+private const val SERIAL_DATA_BITS = 8
+private const val SERIAL_READ_TIMEOUT_MS = 100
+private const val SERIAL_READ_BUFFER_SIZE = 1024
+private const val SERIAL_WRITE_TIMEOUT_MS = 5_000
+private const val SERIAL_GROUP_LOOKUP_TIMEOUT_MS = 2_000L
+private val SERIAL_START_JOIN_TIMEOUT = 10.seconds
+
+private val IS_WINDOWS_HOST = System.getProperty("os.name", "").lowercase().startsWith("windows")
+
+private class SerialPortState {
+ private val lock = SynchronizedObject()
+ private var port: SerialPort? = null
+ private var startJob: Job? = null
+ private var readJob: Job? = null
+ private var closing = false
+
+ fun publishStartJob(job: Job, lifecycleClosed: Boolean): Boolean = synchronized(lock) {
+ val unavailable = closing || lifecycleClosed || startJob?.isCompleted == false || port?.isOpen == true
+ if (unavailable) false else true.also { startJob = job }
+ }
+
+ fun isClosing(lifecycleClosed: Boolean): Boolean = synchronized(lock) { closing || lifecycleClosed }
+
+ fun publishPort(candidate: SerialPort, lifecycleClosed: Boolean): Boolean = synchronized(lock) {
+ val unavailable = closing || lifecycleClosed || port != null
+ if (unavailable) false else true.also { port = candidate }
+ }
+
+ fun currentOpenPort(): SerialPort? = synchronized(lock) { port?.takeIf { !closing && it.isOpen } }
+
+ fun publishReadJob(expectedPort: SerialPort, job: Job, lifecycleClosed: Boolean): Boolean = synchronized(lock) {
+ val unavailable = closing || lifecycleClosed || port !== expectedPort || readJob?.isActive == true
+ if (unavailable) false else true.also { readJob = job }
+ }
+
+ fun canPublish(expectedPort: SerialPort?, requireNoPublishedPort: Boolean): Boolean = synchronized(lock) {
+ !closing && (expectedPort == null || port === expectedPort) && (!requireNoPublishedPort || port == null)
+ }
+
+ fun retirePort(expectedPort: SerialPort): Boolean =
+ synchronized(lock) { (port === expectedPort).also { owned -> if (owned) port = null } }
+
+ fun beginClose(): Job? = synchronized(lock) {
+ closing = true
+ startJob.also { startJob = null }
+ }
+
+ fun takeReadJob(): Job? = synchronized(lock) { readJob.also { readJob = null } }
+
+ fun takePort(): SerialPort? = synchronized(lock) { port.also { port = null } }
+}
+
+private fun writeFullyWithDeadline(port: SerialPort, payload: ByteArray, portName: String, timeoutMs: Int) {
+ val startedNanos = System.nanoTime()
+ val timeoutNanos = timeoutMs.toLong() * NANOS_PER_MILLISECOND
+ var offset = 0
+ while (offset < payload.size) {
+ val written = port.writeBytes(payload, payload.size - offset, offset)
+ if (written < 0) {
+ throw IOException("[$portName] Serial write failed after $offset/${payload.size} bytes")
+ }
+ offset += written
+ if (offset < payload.size) {
+ if (System.nanoTime() - startedNanos >= timeoutNanos) {
+ throw IOException(
+ "[$portName] Serial write timed out after ${timeoutMs}ms at $offset/${payload.size} bytes",
+ )
+ }
+ // Partial writes can make progress while still keeping the driver continuously writable. Yield between
+ // every retry so a slow device cannot turn this bounded loop into a CPU spin.
+ Thread.sleep(SERIAL_WRITE_RETRY_DELAY_MS)
+ }
+ }
+}
+
+private fun configureSerialPort(port: SerialPort, baudRate: Int) {
+ port.setComPortParameters(baudRate, SERIAL_DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
+ // jSerialComm honors write timeouts only on Windows. TIMEOUT_NONBLOCKING is zero, so on other hosts the supplied
+ // write-timeout argument is intentionally inert; writeFullyWithDeadline owns the cross-platform deadline instead.
+ val timeoutMode =
+ SerialPort.TIMEOUT_READ_SEMI_BLOCKING or
+ if (IS_WINDOWS_HOST) SerialPort.TIMEOUT_WRITE_BLOCKING else SerialPort.TIMEOUT_NONBLOCKING
+ port.setComPortTimeouts(timeoutMode, SERIAL_READ_TIMEOUT_MS, SERIAL_WRITE_TIMEOUT_MS)
+}
+
+private fun diagnoseSerialOpenFailure(portName: String): String {
+ val osName = System.getProperty("os.name", "").lowercase()
+ val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
+ val portFile = File(devPath)
+ return when {
+ !osName.contains("linux") -> "Could not open serial port: $portName"
+
+ !portFile.exists() -> "Serial port $portName not found. Is the device still connected?"
+
+ !portFile.canRead() || !portFile.canWrite() -> {
+ val group = detectSerialGroup(devPath)
+ "Permission denied for $devPath. Add your account to the $group group, then log out and back in."
+ }
+
+ else -> "Could not open serial port: $portName"
+ }
+}
+
+private fun detectSerialGroup(devPath: String): String = runCatching {
+ val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
+ try {
+ if (!process.waitFor(SERIAL_GROUP_LOOKUP_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+ Logger.w { "stat lookup for $devPath timed out" }
+ "dialout"
+ } else {
+ val output = process.inputStream.bufferedReader().use { it.readText() }.trim()
+ if (process.exitValue() != 0) {
+ val detail = output.takeIf(String::isNotEmpty)?.let { ": $it" }.orEmpty()
+ Logger.w { "stat lookup for $devPath failed with exit code ${process.exitValue()}$detail" }
+ "dialout"
+ } else {
+ output.takeIf { it.isNotEmpty() && it.none(Char::isWhitespace) } ?: "dialout"
+ }
+ }
+ } finally {
+ process.destroy()
+ if (process.isAlive) process.destroyForcibly()
+ }
+}
+ .getOrDefault("dialout")
+
+private fun closeSerialPort(portName: String, port: SerialPort) {
+ val failure = runCatching { if (port.isOpen) port.closePort() }.exceptionOrNull()
+ if (failure != null) Logger.w(failure) { "[$portName] Failed to close serial port" }
+}
+
+private data class SerialReadStep(val continueReading: Boolean, val failed: Boolean = false)
+
+private fun processSerialRead(
+ readResult: Result<Int>,
+ buffer: ByteArray,
+ portName: String,
+ lifecycle: TransportLifecycleGate,
+ readChar: (Byte) -> Unit,
+): SerialReadStep {
+ val failure = readResult.exceptionOrNull()
+ return when {
+ failure == null -> {
+ val numRead = readResult.getOrThrow()
+ when {
+ numRead == -1 -> SerialReadStep(continueReading = false, failed = true)
+
+ numRead <= 0 -> SerialReadStep(continueReading = true)
+
+ else -> {
+ val admitted = lifecycle.runIfOpen { for (i in 0 until numRead) readChar(buffer[i]) }
+ SerialReadStep(continueReading = admitted != null)
+ }
+ }
+ }
+
+ failure is SerialPortTimeoutException -> SerialReadStep(continueReading = true)
+
+ failure is CancellationException -> throw failure
+
+ failure !is Exception -> throw failure
+
+ else -> {
+ Logger.w(failure) { "[$portName] Serial read error" }
+ SerialReadStep(continueReading = false, failed = true)
+ }
+ }
+}
+
+private suspend fun runSerialReadLoop(
+ port: SerialPort,
+ portName: String,
+ lifecycle: TransportLifecycleGate,
+ readChar: (Byte) -> Unit,
+): Boolean {
+ val input = port.inputStream
+ val buffer = ByteArray(SERIAL_READ_BUFFER_SIZE)
+ var endedUnexpectedly = false
+ try {
+ var reading = true
+ while (currentCoroutineContext().isActive && port.isOpen && reading) {
+ val step = processSerialRead(runCatching { input.read(buffer) }, buffer, portName, lifecycle, readChar)
+ reading = step.continueReading
+ endedUnexpectedly = endedUnexpectedly || step.failed
+ }
+ endedUnexpectedly =
+ if (currentCoroutineContext().isActive) {
+ endedUnexpectedly || !lifecycle.isClosed
+ } else {
+ false
+ }
+ } finally {
+ runCatching { input.close() }
+ }
+ return endedUnexpectedly
+}
+
+private fun publishSerialCallbackIfOpen(
+ lifecycle: TransportLifecycleGate,
+ state: SerialPortState,
+ expectedPort: SerialPort? = null,
+ requireNoPublishedPort: Boolean = false,
+ block: () -> Unit,
+): Boolean = lifecycle.runIfOpen {
+ val publish = state.canPublish(expectedPort, requireNoPublishedPort)
+ if (publish) block()
+ publish
+} ?: false
/**
* JVM-specific implementation of [RadioTransport] using jSerialComm. Uses [StreamTransport] for START1/START2 packet
* framing.
*
- * Use the [open] factory method instead of the constructor directly to ensure the serial port is opened and the read
- * loop is started.
+ * Use the [create] factory method instead of the constructor directly. Construction is side-effect free; [start] opens
+ * the serial port and publishes the connection.
*/
class SerialTransport
private constructor(
@@ -45,198 +271,190 @@ private constructor(
scope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
) : StreamTransport(callback, scope) {
- private var serialPort: SerialPort? = null
- private var readJob: Job? = null
+ private val lifecycle = TransportLifecycleGate("JVM serial")
+ private val portState = SerialPortState()
+ private val heartbeatSender = HeartbeatSender(sendToRadio = { handleSendToRadio(it) }, logTag = "Serial[$portName]")
+
+ override fun start() {
+ val job = scope.launch(dispatchers.io, start = CoroutineStart.LAZY) { runConnectionStart() }
+ if (portState.publishStartJob(job, lifecycle.isClosed)) {
+ job.start()
+ } else {
+ job.cancel()
+ if (lifecycle.isClosed) Logger.d { "[$portName] Ignoring start after serial transport close" }
+ }
+ }
- private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$portName]")
+ private suspend fun runConnectionStart() {
+ val lease = lifecycle.tryAcquire() ?: return
+ try {
+ if (!startConnection() && !lifecycle.isClosed) {
+ val errorMessage = diagnoseSerialOpenFailure(portName)
+ Logger.w { "[$portName] Serial port could not be opened; signalling disconnect" }
+ publishSerialCallbackIfOpen(lifecycle, portState, requireNoPublishedPort = true) {
+ callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
+ }
+ }
+ } finally {
+ lease.release()
+ }
+ }
/** Attempts to open the serial port and starts the read loop. Returns true if successful, false otherwise. */
- private fun startConnection(): Boolean {
- return try {
- val port = SerialPort.getCommPort(portName) ?: return false
- port.setComPortParameters(baudRate, DATA_BITS, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY)
- port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, READ_TIMEOUT_MS, 0)
- if (port.openPort()) {
- serialPort = port
- port.setDTR()
- port.setRTS()
- Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
- super.connect() // Sends WAKE_BYTES and signals callback.onConnect()
- startReadLoop(port)
- true
+ private suspend fun startConnection(): Boolean {
+ if (portState.isClosing(lifecycle.isClosed)) return false
+ var candidatePort: SerialPort? = null
+ val attempt = runCatching {
+ val port = SerialPort.getCommPort(portName)
+ if (port == null) {
+ false
} else {
- Logger.w { "[$portName] Serial port openPort() returned false" }
+ candidatePort = port
+ configureSerialPort(port, baudRate)
+ if (!runInterruptible(dispatchers.io) { port.openPort() }) {
+ Logger.w { "[$portName] Serial port openPort() returned false" }
+ closeSerialPort(portName, port)
+ candidatePort = null
+ false
+ } else {
+ port.setDTR()
+ port.setRTS()
+ if (!portState.publishPort(port, lifecycle.isClosed)) {
+ closeSerialPort(portName, port)
+ candidatePort = null
+ false
+ } else {
+ candidatePort = null
+ finishPublishedPort(port)
+ }
+ }
+ }
+ }
+ val failure = attempt.exceptionOrNull()
+ candidatePort?.let { closeSerialPort(portName, it) }
+ return when {
+ failure == null -> attempt.getOrDefault(false)
+
+ failure is CancellationException -> {
+ portState.takePort()?.let { closeSerialPort(portName, it) }
+ throw failure
+ }
+
+ failure !is Exception -> {
+ portState.takePort()?.let { closeSerialPort(portName, it) }
+ throw failure
+ }
+
+ else -> {
+ portState.takePort()?.let { closeSerialPort(portName, it) }
+ Logger.w(failure) { "[$portName] Serial connection failed" }
false
}
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[$portName] Serial connection failed" }
- false
}
}
- @Suppress("CyclomaticComplexMethod")
+ private suspend fun finishPublishedPort(port: SerialPort): Boolean {
+ Logger.i { "[$portName] Serial port opened (baud=$baudRate)" }
+ runInterruptible(dispatchers.io) {
+ writeFullyWithDeadline(port, StreamFrameCodec.WAKE_BYTES, portName, SERIAL_WRITE_TIMEOUT_MS)
+ }
+ val connected = publishSerialCallbackIfOpen(lifecycle, portState, expectedPort = port) { callback.onConnect() }
+ if (connected) startReadLoop(port)
+ return connected
+ }
+
private fun startReadLoop(port: SerialPort) {
- Logger.d { "[$portName] Starting serial read loop" }
- readJob =
- scope.launch(dispatchers.io) {
- val input = port.inputStream
- val buffer = ByteArray(READ_BUFFER_SIZE)
- try {
- var reading = true
- while (isActive && port.isOpen && reading) {
- try {
- val numRead = input.read(buffer)
- if (numRead == -1) {
- reading = false
- } else if (numRead > 0) {
- for (i in 0 until numRead) {
- readChar(buffer[i])
- }
- }
- } catch (_: SerialPortTimeoutException) {
- // Expected timeout when no data is available
- } catch (e: CancellationException) {
- throw e
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- if (isActive) {
- Logger.w(e) { "[$portName] Serial read error" }
- } else {
- Logger.d { "[$portName] Serial read interrupted by cancellation" }
- }
- reading = false
- }
- }
- } catch (e: CancellationException) {
- throw e
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- if (isActive) {
- Logger.w(e) { "[$portName] Serial read loop outer error" }
- } else {
- Logger.d { "[$portName] Serial read loop interrupted by cancellation" }
- }
- } finally {
- Logger.d { "[$portName] Serial read loop exiting" }
- try {
- input.close()
- } catch (_: Exception) {
- // Ignore errors during input stream close
- }
- try {
- if (port.isOpen) {
- port.closePort()
- }
- } catch (_: Exception) {
- // Ignore errors during port close
- }
- if (isActive) {
- // Serial read loop ended unexpectedly (cable unplug, I/O error). Treat as
- // transient — the user did not explicitly disconnect, and the port may come
- // back when the device is replugged or the OS re-enumerates it.
+ val job =
+ scope.launch(dispatchers.io, start = CoroutineStart.LAZY) {
+ val endedUnexpectedly = runSerialReadLoop(port, portName, lifecycle, ::readChar)
+ Logger.d { "[$portName] Serial read loop exiting" }
+ if (portState.retirePort(port)) closeSerialPort(portName, port)
+ if (endedUnexpectedly) {
+ publishSerialCallbackIfOpen(lifecycle, portState, requireNoPublishedPort = true) {
onDeviceDisconnect(waitForStopped = true, isPermanent = false)
}
}
}
+ if (portState.publishReadJob(port, job, lifecycle.isClosed)) {
+ Logger.d { "[$portName] Starting serial read loop" }
+ job.start()
+ } else {
+ job.cancel()
+ }
+ }
+
+ override fun handleSendToRadio(p: ByteArray): Boolean {
+ val lease = lifecycle.tryAcquire() ?: return false
+ val port = portState.currentOpenPort()
+ return if (port == null) {
+ lease.release()
+ false
+ } else {
+ queueFramedSend(
+ payload = p,
+ writer = { bytes ->
+ runInterruptible(dispatchers.io) {
+ writeFullyWithDeadline(port, bytes, portName, SERIAL_WRITE_TIMEOUT_MS)
+ }
+ },
+ flusher = {},
+ onCompletion = lease::release,
+ )
+ }
}
override fun sendBytes(p: ByteArray) {
- serialPort?.takeIf { it.isOpen }?.outputStream?.write(p)
+ portState.currentOpenPort()?.let { port -> writeFullyWithDeadline(port, p, portName, SERIAL_WRITE_TIMEOUT_MS) }
}
override fun flushBytes() {
- serialPort?.takeIf { it.isOpen }?.outputStream?.flush()
+ // Raw SerialPort.writeBytes writes directly to the driver buffer; there is no user-space stream to flush.
}
override fun keepAlive() {
- // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the
- // serial link is alive.
- scope.launch { heartbeatSender.sendHeartbeat() }
- }
-
- private fun closePortResources() {
- serialPort?.takeIf { it.isOpen }?.closePort()
- serialPort = null
+ scope.handledLaunch { heartbeatSender.sendHeartbeat() }
}
override suspend fun close() {
- Logger.d { "[$portName] Closing serial transport" }
- readJob?.cancel()
- readJob = null
- closePortResources()
- super.close()
+ withContext(NonCancellable) {
+ Logger.d { "[$portName] Closing serial transport" }
+ val currentStartJob = portState.beginClose()
+ if (currentStartJob != null) {
+ currentStartJob.cancel()
+ val joined = withTimeoutOrNull(SERIAL_START_JOIN_TIMEOUT) { currentStartJob.join() } != null
+ if (!joined) {
+ Logger.w { "[$portName] Serial open did not stop within $SERIAL_START_JOIN_TIMEOUT" }
+ }
+ }
+ // beginClose makes new sends reject synchronously. Stop the framed-send worker now so queued sends release
+ // their lifecycle leases before the gate waits for admitted operations.
+ super.close()
+ val completed =
+ lifecycle.close {
+ val currentReadJob = portState.takeReadJob()
+ runInterruptible(dispatchers.io) { portState.takePort()?.let { closeSerialPort(portName, it) } }
+ currentReadJob?.cancelAndJoin()
+ }
+ if (!completed) Logger.w { "[$portName] JVM serial teardown did not complete within its lifecycle bounds" }
+ }
}
companion object {
private const val DEFAULT_BAUD_RATE = 115200
- private const val DATA_BITS = 8
- private const val READ_BUFFER_SIZE = 1024
- private const val READ_TIMEOUT_MS = 100
/**
- * Creates and opens a [SerialTransport]. If the port cannot be opened, the transport signals a transient
- * disconnect to the [callback] and returns the (non-connected) instance. The open failure is treated as
- * non-permanent so higher-layer reconnect orchestration can retry (e.g. when the device is replugged or the
- * user grants permission); only an explicit close should signal a permanent disconnect.
+ * Creates a side-effect-free [SerialTransport]. The owning transport service invokes [SerialTransport.start]
+ * after it has published session ownership, so even immediate serial callbacks carry the correct generation.
*/
- fun open(
+ fun create(
portName: String,
baudRate: Int = DEFAULT_BAUD_RATE,
callback: RadioTransportCallback,
scope: CoroutineScope,
dispatchers: CoroutineDispatchers,
- ): SerialTransport {
- val transport = SerialTransport(portName, baudRate, callback, scope, dispatchers)
- if (!transport.startConnection()) {
- val errorMessage = diagnoseOpenFailure(portName)
- Logger.w { "[$portName] Serial port could not be opened; signalling disconnect. $errorMessage" }
- callback.onDisconnect(isPermanent = false, errorMessage = errorMessage)
- }
- return transport
- }
+ ): SerialTransport = SerialTransport(portName, baudRate, callback, scope, dispatchers)
- /**
- * Discovers and returns a list of available serial ports. Returns a list of the system port names (e.g.,
- * "COM3", "/dev/ttyUSB0").
- */
+ /** Returns names of all available serial ports. */
fun getAvailablePorts(): List<String> = SerialPort.getCommPorts().map { it.systemPortName }
-
- /**
- * Diagnoses why a serial port could not be opened and returns a user-facing error message. On Linux, checks
- * file permissions and suggests the appropriate group fix.
- */
- @Suppress("ReturnCount")
- private fun diagnoseOpenFailure(portName: String): String {
- val osName = System.getProperty("os.name", "").lowercase()
- if (!osName.contains("linux")) {
- return "Could not open serial port: $portName"
- }
-
- // jSerialComm resolves bare names like "ttyUSB0" to "/dev/ttyUSB0"
- val devPath = if (portName.startsWith("/")) portName else "/dev/$portName"
- val portFile = File(devPath)
- if (!portFile.exists()) {
- return "Serial port $portName not found. Is the device still connected?"
- }
- if (!portFile.canRead() || !portFile.canWrite()) {
- val group = detectSerialGroup(devPath)
- val user = System.getProperty("user.name", "your_user")
- return "Permission denied for $devPath. " +
- "Run: sudo usermod -aG $group $user — then log out and back in."
- }
- return "Could not open serial port: $portName"
- }
-
- /**
- * Attempts to detect the group that owns the serial device file. Falls back to "dialout" (Debian/Ubuntu
- * default) if detection fails.
- */
- @Suppress("SwallowedException", "TooGenericExceptionCaught")
- private fun detectSerialGroup(devPath: String): String = try {
- val process = ProcessBuilder("stat", "-c", "%G", devPath).redirectErrorStream(true).start()
- val group = process.inputStream.bufferedReader().readText().trim()
- process.waitFor()
- group.ifEmpty { "dialout" }
- } catch (e: Exception) {
- "dialout"
- }
}
}
diff --git a/core/repository/README.md b/core/repository/README.md
index 12bf9b0c7c..269fa15682 100644
--- a/core/repository/README.md
+++ b/core/repository/README.md
@@ -78,13 +78,27 @@ Raw hardware I/O contract for all physical transports (BLE, USB, TCP, Mock).
```kotlin
interface RadioTransport {
- fun handleSendToRadio(p: ByteArray)
- fun start()
- fun keepAlive()
+ fun handleSendToRadio(p: ByteArray): Boolean
+ fun start() {}
+ fun keepAlive() {}
suspend fun close()
}
```
+`handleSendToRadio` reports synchronous admission only: `true` means the transport accepted the bytes for asynchronous
+handoff, not that the device received them. `false` means the transport is unavailable, closed, or unable to schedule
+the handoff; delivery confirmation is a separate protocol concern.
+
+`RadioTransportWriter` is the service-facing half of the same admission contract. `trySendToRadio` must likewise return
+promptly and reports only whether the active transport accepted the bytes for asynchronous delivery.
+
+```kotlin
+interface RadioTransportWriter {
+ fun sendToRadio(bytes: ByteArray)
+ fun trySendToRadio(bytes: ByteArray): Boolean
+}
+```
+
### `ServiceRepository`
The primary reactive bridge between the long-running mesh service and all feature/UI layers.
@@ -92,7 +106,9 @@ Decomposed into focused sub-interfaces via Interface Segregation Principle:
```kotlin
interface ConnectionStateProvider {
+ val connectionLifecycle: StateFlow<ConnectionLifecycle>
val connectionState: StateFlow<ConnectionState>
+ val connectionEpochs: StateFlow<ConnectionEpochs>
}
interface TracerouteResponseProvider {
diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts
index 72c5f34106..ad6f61a88f 100644
--- a/core/repository/build.gradle.kts
+++ b/core/repository/build.gradle.kts
@@ -31,6 +31,7 @@ kotlin {
implementation(projects.core.database)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.atomicfu)
implementation(libs.kermit)
implementation(libs.androidx.paging.common)
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
index 35051e31e9..9bfab0acf4 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AdminController.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.repository
+import kotlinx.coroutines.NonCancellable
import org.meshtastic.core.model.Position
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -28,7 +29,9 @@ import org.meshtastic.proto.User
*
* Mirrors the SDK's `AdminApi` interface — local and remote configuration, channel management, owner identity, device
* lifecycle commands, and batch edit sessions. When the SDK is adopted, this interface becomes the adapter boundary:
- * implementations delegate to `RadioClient.admin`.
+ * implementations delegate to `RadioClient.admin`. Operations that enqueue outbound admin packets can throw
+ * [PacketQueueRejectedException] when the current transport cannot admit the handoff. “Fire-and-forget” means no device
+ * response is awaited, not that local queue rejection is silently ignored.
*
* @see RadioController which extends this interface for backward compatibility
*/
@@ -145,7 +148,22 @@ interface AdminController {
* SDK's `AdminApi.editSettings { }`.
*
* All admin packets for the session — begin, the [block]'s writes, and commit — are issued from the calling
- * coroutine, which is required for the firmware to associate them with one transaction.
+ * coroutine, which is required for the firmware to associate them with one transaction. The implementation waits
+ * for radio queue acceptance at both boundaries, so callers cannot start a later rebooting stage while this
+ * transaction is still queued or uncommitted.
+ *
+ * For the locally connected node, a commit dispatched immediately before the expected transport departure is
+ * treated as accepted because the reboot can prevent an acknowledgement from returning. Local owner, config,
+ * module-config, and fixed-position projections are staged until that commit is accepted; they are discarded when
+ * the block or commit fails so local state cannot describe settings the device did not commit.
+ *
+ * Firmware exposes no abort boundary, so commit is attempted in [NonCancellable] context even when [block] throws
+ * or the caller is cancelled. The original block failure is rethrown, with a distinct commit failure attached as a
+ * suppressed exception. When [block] succeeds and only the commit fails, the commit failure itself is thrown and
+ * the staged projections are discarded.
+ *
+ * @throws EditSettingsTransactionException when the radio rejects or does not answer the begin or commit boundary.
+ * @throws PacketQueueRejectedException when the outbound queue refuses a transactional write issued by [block].
*/
suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit)
@@ -167,7 +185,10 @@ interface AdminEditScope {
/** Updates a module configuration on the session's node. */
suspend fun setModuleConfig(config: ModuleConfig)
- /** Updates a channel configuration on the session's node. */
+ /**
+ * Updates a channel configuration on the session's node. The batch operation that owns the complete target set is
+ * responsible for authoritative local-cache reconciliation after [AdminController.editSettings] returns.
+ */
suspend fun setChannel(channel: Channel)
/** Sets a fixed position on the session's node. */
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AwaitedSendResult.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AwaitedSendResult.kt
new file mode 100644
index 0000000000..395182bdd0
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AwaitedSendResult.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+/** Outcome of a packet whose caller waited for transport admission and any required routing result. */
+enum class AwaitedSendStatus {
+ /** The radio acknowledged the packet, or firmware completed synchronous local-loopback delivery. */
+ ACCEPTED,
+
+ /** The app rejected the packet before it reached a transport, such as an invalid or duplicate packet ID. */
+ REJECTED,
+
+ /** The transport dispatched the packet, but the radio rejected it. */
+ RADIO_REJECTED,
+
+ /** No required radio response arrived within the response window after transport admission. */
+ TIMED_OUT,
+
+ /**
+ * The queue or owning service scope stopped before the radio answered. Inspect [AwaitedSendResult.dispatched]
+ * before retrying because the transport can stop after admission.
+ */
+ TRANSPORT_STOPPED,
+
+ /** No transport accepted the bytes, or the send attempt raised an error. */
+ SEND_FAILED,
+}
+
+/**
+ * Detailed result for an awaited send. [departureEpochAtDispatch] captures the canonical departure counter when an
+ * active transport accepts the outbound bytes for asynchronous delivery. Its presence is also the single source of
+ * truth for [dispatched], allowing callers to distinguish a later transport departure from one that happened while the
+ * packet was still queued. Correlated responses received before dispatch are ignored, so an accepted result always
+ * belongs to an admitted transport send.
+ */
+data class AwaitedSendResult(val status: AwaitedSendStatus, val departureEpochAtDispatch: Long? = null) {
+ /** True only when an active transport admitted the outbound bytes. */
+ val dispatched: Boolean
+ get() = departureEpochAtDispatch != null
+
+ val accepted: Boolean
+ get() = status == AwaitedSendStatus.ACCEPTED
+
+ // TRANSPORT_STOPPED intentionally has no dispatch invariant: teardown can win before or after byte admission.
+ init {
+ require(status != AwaitedSendStatus.REJECTED || !dispatched) {
+ "a REJECTED result must not come from an admitted transport send"
+ }
+ require(status != AwaitedSendStatus.RADIO_REJECTED || dispatched) {
+ "a RADIO_REJECTED result must come from an admitted transport send"
+ }
+ require(status != AwaitedSendStatus.ACCEPTED || dispatched) {
+ "an ACCEPTED result must come from an admitted transport send"
+ }
+ require(status != AwaitedSendStatus.TIMED_OUT || dispatched) {
+ "a TIMED_OUT result must come from an admitted transport send"
+ }
+ require(status != AwaitedSendStatus.SEND_FAILED || !dispatched) {
+ "a SEND_FAILED result must not claim transport dispatch"
+ }
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
index 32339888a3..a5eeae3c41 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/CommandSender.kt
@@ -17,10 +17,11 @@
package org.meshtastic.core.repository
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.Position
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
+import org.meshtastic.core.model.Position as ModelPosition
+import org.meshtastic.proto.Position as ProtoPosition
/** Interface for sending commands and packets to the mesh network. */
@Suppress("TooManyFunctions")
@@ -37,10 +38,14 @@ interface CommandSender {
/** Generates a new unique packet ID. */
fun generatePacketId(): Int
- /** Sends a data packet to the mesh. */
+ /**
+ * Sends a data packet to the mesh. If the outbound queue refuses admission, the packet is marked
+ * [org.meshtastic.core.model.MessageStatus.ERROR] and [PacketQueueRejectedException] is thrown so the persistence
+ * owner can requeue or fail its durable record instead of treating a normal return as successful admission.
+ */
suspend fun sendData(p: DataPacket)
- /** Sends an admin message to a specific node. */
+ /** Sends an admin message to a specific node, or throws if the outbound queue rejects it. */
suspend fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),
@@ -48,6 +53,22 @@ interface CommandSender {
initFn: () -> AdminMessage,
)
+ /**
+ * Sends an admin message only if [expectedConnectionVersion] still owns queue admission.
+ *
+ * Pass the `version` from [ConnectionStateProvider.connectionLifecycle] captured by the caller.
+ *
+ * @throws PacketQueueRejectedException when the expected version is stale or the outbound queue otherwise refuses
+ * the command.
+ */
+ suspend fun sendAdminForConnection(
+ destNum: Int,
+ expectedConnectionVersion: Long,
+ requestId: Int = generatePacketId(),
+ wantResponse: Boolean = false,
+ initFn: () -> AdminMessage,
+ )
+
/**
* Sends an admin message immediately, bypassing the outbound packet queue. The queue only drains while the
* connection state is Connected, so mid-handshake sends (e.g. set_time_only at MyNodeInfo) must use this path or
@@ -59,7 +80,8 @@ interface CommandSender {
* Sends an admin message and suspends until firmware processes it and returns a routing acknowledgement.
*
* This is used when the caller needs a processing barrier before proceeding, such as sending a shared contact
- * before the first DM to a node.
+ * before the first DM to a node. Time spent behind existing FIFO entries does not count against the
+ * routing-response timeout; the timeout starts only after an active transport admits this packet.
*
* @return `true` on a routing ACK or synchronous local-loopback delivery (`ERRNO_SHOULD_RELEASE`); `false` when
* disconnected, transport sending fails, a routing NAK or queue rejection arrives, or the operation times out.
@@ -69,27 +91,59 @@ interface CommandSender {
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: () -> AdminMessage,
- ): Boolean
+ ): Boolean = sendAdminAwaitResult(destNum, requestId, wantResponse, initFn).accepted
- /** Sends our current position to the mesh. */
- suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
+ /**
+ * Detailed form of [sendAdminAwait], including whether an active transport admitted the packet. Queue or transport
+ * rejection is represented by a non-accepted [AwaitedSendResult] with `dispatched == false`; it is not reported as
+ * [PacketQueueRejectedException].
+ */
+ suspend fun sendAdminAwaitResult(
+ destNum: Int,
+ requestId: Int = generatePacketId(),
+ wantResponse: Boolean = false,
+ initFn: () -> AdminMessage,
+ ): AwaitedSendResult
+
+ /** Sends our current position to the mesh, or throws if the outbound queue rejects it. */
+ suspend fun sendPosition(pos: ProtoPosition, destNum: Int? = null, wantResponse: Boolean = false)
- /** Requests the position of a specific node. */
- suspend fun requestPosition(destNum: Int, currentPosition: Position)
+ /** Requests the position of a specific node, or throws if the outbound queue rejects it. */
+ suspend fun requestPosition(destNum: Int, currentPosition: ModelPosition)
- /** Sets a fixed position for a node. */
- suspend fun setFixedPosition(destNum: Int, pos: Position)
+ /** Sets a fixed position for a node, or throws if the outbound queue rejects the admin command. */
+ suspend fun setFixedPosition(destNum: Int, pos: ModelPosition)
- /** Requests user info from a specific node. */
+ /** Requests user info from a specific node, or throws if the outbound queue rejects it. */
suspend fun requestUserInfo(destNum: Int)
- /** Requests a traceroute to a specific node. */
+ /** Requests a traceroute to a specific node, or throws if the outbound queue rejects it. */
suspend fun requestTraceroute(requestId: Int, destNum: Int)
- /** Requests telemetry from a specific node. */
+ /** Requests telemetry from a specific node, or throws if the outbound queue rejects it. */
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
- /** Requests neighbor info from a specific node. */
+ /**
+ * Requests telemetry only if [expectedConnectionVersion] still owns queue admission.
+ *
+ * Pass the `version` from [ConnectionStateProvider.connectionLifecycle] captured by the caller.
+ *
+ * @throws PacketQueueRejectedException when the expected version is stale or the outbound queue otherwise refuses
+ * the request.
+ */
+ suspend fun requestTelemetryForConnection(
+ requestId: Int,
+ destNum: Int,
+ typeValue: Int,
+ expectedConnectionVersion: Long,
+ )
+
+ /**
+ * Requests neighbor info from a specific node.
+ *
+ * @throws LocalNodeUnavailableException when the local node identity is unavailable before admission.
+ * @throws PacketQueueRejectedException when the outbound queue rejects the request.
+ */
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
/**
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateHolder.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateHolder.kt
new file mode 100644
index 0000000000..4be95f31e2
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateHolder.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionLifecycle
+import org.meshtastic.core.model.ConnectionState
+
+/** Owns canonical connection state, epochs, and compatibility projections under one publication lock. */
+class ConnectionStateHolder(
+ initialState: ConnectionState = ConnectionState.Disconnected,
+ initialEpochs: ConnectionEpochs = ConnectionEpochs(),
+) : ConnectionStateProvider {
+ init {
+ require(initialEpochs.isSelfConsistent()) { "initialEpochs must satisfy connection epoch invariants" }
+ }
+
+ private val publicationLock = SynchronizedObject()
+
+ private val mutableConnectionLifecycle =
+ MutableStateFlow(ConnectionLifecycle(version = 0, state = initialState, epochs = initialEpochs))
+ private val mutableConnectionState = MutableStateFlow(initialState)
+ private val mutableConnectionEpochs = MutableStateFlow(initialEpochs)
+
+ override val connectionLifecycle: StateFlow<ConnectionLifecycle> = mutableConnectionLifecycle.asStateFlow()
+ override val connectionState: StateFlow<ConnectionState> = mutableConnectionState.asStateFlow()
+ override val connectionEpochs: StateFlow<ConnectionEpochs> = mutableConnectionEpochs.asStateFlow()
+
+ /** Applies [newState] and advances epochs exactly once when the state changes. */
+ fun setConnectionState(newState: ConnectionState) = synchronized(publicationLock) {
+ val current = mutableConnectionLifecycle.value
+ if (current.state != newState) {
+ publish(
+ ConnectionLifecycle(
+ version = current.version + 1,
+ state = newState,
+ epochs = current.epochs.advance(current.state, newState),
+ ),
+ )
+ }
+ }
+
+ /**
+ * Restores a known state, primarily for reusable test fakes. Epoch counters remain monotonic: omitting [epochs]
+ * applies the canonical transition into [state], while an explicit value may only advance the counters. The
+ * publication version always advances so readers can distinguish the reset from the preceding snapshot.
+ */
+ fun reset(state: ConnectionState = ConnectionState.Disconnected, epochs: ConnectionEpochs? = null) =
+ synchronized(publicationLock) {
+ val current = mutableConnectionLifecycle.value
+ val minimumEpochs = current.epochs.advance(current.state, state)
+ val nextEpochs = epochs ?: minimumEpochs
+ val preservesCanonicalDeparture =
+ nextEpochs.departures != minimumEpochs.departures ||
+ minimumEpochs.lastDepartureState == null ||
+ nextEpochs.lastDepartureState == minimumEpochs.lastDepartureState
+ require(
+ nextEpochs.departures >= minimumEpochs.departures &&
+ nextEpochs.completedHandshakes >= minimumEpochs.completedHandshakes &&
+ nextEpochs.handshakesAtLastDeparture >= minimumEpochs.handshakesAtLastDeparture &&
+ nextEpochs.isSelfConsistent() &&
+ preservesCanonicalDeparture,
+ ) {
+ "reset must not rewind epoch counters or contradict canonical departure evidence"
+ }
+ publish(ConnectionLifecycle(version = current.version + 1, state = state, epochs = nextEpochs))
+ }
+
+ /** Publishes one authoritative snapshot and both legacy mirrors as one non-suspending critical section. */
+ private fun publish(next: ConnectionLifecycle) {
+ mutableConnectionLifecycle.value = next
+ // Epochs before state: state collectors that then read connectionEpochs must not observe lagging counters.
+ mutableConnectionEpochs.value = next.epochs
+ mutableConnectionState.value = next.state
+ }
+}
+
+private fun ConnectionEpochs.isSelfConsistent(): Boolean = departures >= 0L &&
+ completedHandshakes >= 0L &&
+ handshakesAtLastDeparture >= 0L &&
+ handshakesAtLastDeparture <= completedHandshakes &&
+ (departures > 0L || handshakesAtLastDeparture == 0L) &&
+ lastDepartureState !is ConnectionState.Connected &&
+ (departures == 0L) == (lastDepartureState == null)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt
index b7403fcc3c..45b22e4111 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/ConnectionStateProvider.kt
@@ -17,6 +17,8 @@
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionLifecycle
import org.meshtastic.core.model.ConnectionState
/**
@@ -29,6 +31,14 @@ import org.meshtastic.core.model.ConnectionState
* @see ServiceRepository for the full read/write interface
*/
interface ConnectionStateProvider {
+ /**
+ * Atomically correlated canonical state and lifecycle evidence.
+ *
+ * Lifecycle-sensitive code must observe this flow rather than combining independent reads from [connectionState]
+ * and [connectionEpochs].
+ */
+ val connectionLifecycle: StateFlow<ConnectionLifecycle>
+
/**
* Canonical app-level connection state.
*
@@ -37,4 +47,11 @@ interface ConnectionStateProvider {
* @see ServiceRepository.connectionState
*/
val connectionState: StateFlow<ConnectionState>
+
+ /**
+ * Convenience view of monotonic departures and completed handshakes. Use [connectionLifecycle] when the counters
+ * must be correlated with a specific state, because independent StateFlow collections can observe different
+ * versions.
+ */
+ val connectionEpochs: StateFlow<ConnectionEpochs>
}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/EditSettingsTransactionException.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/EditSettingsTransactionException.kt
new file mode 100644
index 0000000000..18f14e9378
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/EditSettingsTransactionException.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.core.common.log.ExpectedCondition
+
+/** An expected radio rejection or timeout at an edit-settings transaction boundary. */
+class EditSettingsTransactionException(message: String, cause: Throwable? = null) :
+ Exception(message, cause),
+ ExpectedCondition {
+ override val expectedConditionLabel: String = "edit-settings-boundary-rejected"
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FixedPositionAdminMessage.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FixedPositionAdminMessage.kt
new file mode 100644
index 0000000000..3a007163c4
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/FixedPositionAdminMessage.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.core.model.Position
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.Position as ProtoPosition
+
+/** Converts the model position to the exact protobuf payload used by fixed-position admin commands. */
+fun Position.toFixedPositionProto(): ProtoPosition =
+ ProtoPosition(latitude_i = Position.degI(latitude), longitude_i = Position.degI(longitude), altitude = altitude)
+
+/** Builds the device admin command for setting or removing a fixed position. */
+fun Position.toFixedPositionAdminMessage(): AdminMessage = if (isFixedPositionRemoval()) {
+ AdminMessage(remove_fixed_position = true)
+} else {
+ AdminMessage(set_fixed_position = toFixedPositionProto())
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt
index 1cf46034fb..48c4355da2 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/HistoryManager.kt
@@ -27,12 +27,16 @@ interface HistoryManager {
* @param myNodeNum The local node number.
* @param storeForwardConfig The store-and-forward module configuration.
* @param transport The transport method being used (for logging).
+ * @param expectedConnectionVersion The connected lifecycle generation that owns this request.
+ * @throws LocalNodeUnavailableException when no device is selected or the local node number is unknown.
+ * @throws PacketQueueRejectedException when the expected lifecycle generation is stale or queue admission fails.
*/
suspend fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
transport: String,
+ expectedConnectionVersion: Long,
)
/**
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocalNodeUnavailableException.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocalNodeUnavailableException.kt
new file mode 100644
index 0000000000..fef5f47067
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/LocalNodeUnavailableException.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.core.common.log.ExpectedCondition
+
+/** Thrown when a command requires the local node identity before that identity is available. */
+class LocalNodeUnavailableException(operation: String) :
+ IllegalStateException("$operation requires an available local node identity"),
+ ExpectedCondition {
+ override val expectedConditionLabel: String = "local-node-unavailable"
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
index 4a3f364044..a913948078 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketHandler.kt
@@ -25,8 +25,27 @@ interface PacketHandler {
/** Sends a command/packet directly to the radio. */
fun sendToRadio(p: ToRadio)
- /** Adds a mesh packet to the queue for sending. */
- suspend fun sendToRadio(packet: MeshPacket)
+ /**
+ * Adds a mesh packet to the queue for sending.
+ *
+ * A completed packet ID may be reused by a later retry. A duplicate is rejected only while that ID is still queued
+ * or in flight, preserving single ownership of its response.
+ *
+ * @return `true` when the packet's non-zero ID was reserved and queued, or `false` when the packet was invalid, its
+ * ID was already reserved, the radio is not connected, or the owning service scope has shut down.
+ */
+ suspend fun sendToRadio(packet: MeshPacket): Boolean
+
+ /**
+ * Adds [packet] only while [expectedConnectionVersion] still owns the connected lifecycle generation.
+ *
+ * This is the admission path for work captured by a specific connection and closes the check-then-send race across
+ * disconnect/reconnect.
+ *
+ * @return `true` when the expected generation is still current and the packet is admitted, or `false` when the
+ * generation is stale or admission fails for any reason documented by [sendToRadio].
+ */
+ suspend fun sendToRadioForConnection(packet: MeshPacket, expectedConnectionVersion: Long): Boolean
/**
* Adds a mesh packet to the queue and suspends until its routing acknowledgement arrives.
@@ -35,16 +54,26 @@ interface PacketHandler {
* prove that a self-addressed admin command has been processed. This stricter acknowledgement is required when a
* later packet depends on that command, such as installing a shared contact before sending the first DM.
*
+ * Time spent behind packets already in the FIFO is not part of the response timeout. The timeout begins after an
+ * active transport admits this packet and ends on its routing ACK/NAK or synchronous local-loopback result.
+ *
* @return `true` on a routing ACK or synchronous local-loopback delivery (`ERRNO_SHOULD_RELEASE`); `false` when
* disconnected, transport sending fails, a routing NAK or queue rejection arrives, or the operation times out.
*/
- suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean
+ suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean = sendToRadioAndAwaitResult(packet).accepted
+
+ /** Detailed form of [sendToRadioAndAwait], including whether an active transport admitted the packet. */
+ suspend fun sendToRadioAndAwaitResult(packet: MeshPacket): AwaitedSendResult
/** Processes queue status updates from the radio. */
fun handleQueueStatus(queueStatus: QueueStatus)
- /** Removes and completes a pending response for a request before the caller's lifecycle lease is released. */
- suspend fun removeResponse(dataRequestId: Int, complete: Boolean)
+ /**
+ * Completes the strict routing response for [dataRequestId] when an active transport already dispatched that
+ * packet. Replies that arrive before dispatch are stale and ignored. The packet ID remains reserved until both the
+ * firmware queue stage and this routing stage are terminal.
+ */
+ suspend fun completeDispatchedResponse(dataRequestId: Int, complete: Boolean)
/** Stops the packet queue. */
fun stopPacketQueue()
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketQueueRejectedException.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketQueueRejectedException.kt
new file mode 100644
index 0000000000..7168d20fca
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketQueueRejectedException.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import org.meshtastic.core.common.log.ExpectedCondition
+
+/** Thrown when the outbound packet queue refuses admission for an operation. */
+class PacketQueueRejectedException(operation: String) :
+ IllegalStateException("$operation was rejected by the outbound packet queue"),
+ ExpectedCondition {
+ override val expectedConditionLabel: String = "packet-queue-rejected"
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
index 6f5c28c290..1ee73dff3c 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PacketRepository.kt
@@ -33,6 +33,12 @@ data class PersistedPacketId(val myNodeNum: Int, val uuid: Long)
/** A persisted packet paired with the stable row identity needed by durable background work. */
data class PersistedPacket(val id: PersistedPacketId, val packet: DataPacket)
+/** Stable identity of one persisted reaction row within its owning node database. */
+data class PersistedReactionId(val myNodeNum: Int, val replyId: Int, val userId: String, val emoji: String)
+
+/** A persisted reaction paired with the exact row identity needed by durable background work. */
+data class PersistedReaction(val id: PersistedReactionId, val reaction: Reaction)
+
/**
* Repository interface for managing mesh packets and message history.
*
@@ -83,6 +89,9 @@ interface PacketRepository {
/** Returns all sent packets still awaiting a routing ACK/NAK, including stable persisted-row identities. */
suspend fun getEnroutePackets(): List<PersistedPacket>
+ /** Returns all sent reactions still awaiting a routing ACK/NAK, including stable persisted-row identities. */
+ suspend fun getEnrouteReactions(): List<PersistedReaction>
+
/**
* Atomically marks a still-[MessageStatus.ENROUTE] packet as failed with [routingError], leaving it untouched if an
* ACK/NAK already resolved it.
@@ -91,6 +100,14 @@ interface PacketRepository {
*/
suspend fun timeOutEnroutePacket(id: PersistedPacketId, routingError: Int): Boolean
+ /**
+ * Atomically marks a still-[MessageStatus.ENROUTE] reaction as failed with [routingError], leaving it untouched if
+ * an ACK/NAK already resolved it.
+ *
+ * @return true if a reaction was timed out.
+ */
+ suspend fun timeOutEnrouteReaction(id: PersistedReactionId, routingError: Int): Boolean
+
/**
* Persists a packet in the database.
*
@@ -160,6 +177,24 @@ interface PacketRepository {
*/
suspend fun updateOutgoingMessageStatus(packet: MeshPacket, status: MessageStatus): PersistedPacketId?
+ /**
+ * Resolves the single persisted row matching an outgoing mesh packet without changing its status. Returns null when
+ * the row is not present yet or the available packet identity is ambiguous.
+ */
+ suspend fun resolveOutgoingPacket(packet: MeshPacket): PersistedPacket?
+
+ /**
+ * Atomically resolves and conditionally applies a queue-stage packet status, returning the pre-update row. Returns
+ * null when the row is not persisted yet or when the mesh identity matches more than one row.
+ */
+ suspend fun applyOutgoingQueueStatus(packet: MeshPacket, status: MessageStatus): PersistedPacket?
+
+ /**
+ * Atomic reaction equivalent of [applyOutgoingQueueStatus]. Returns null when no non-received reaction row carries
+ * [packetId] or when [packetId] matches more than one such row.
+ */
+ suspend fun applyOutgoingReactionQueueStatus(packetId: Int, status: MessageStatus): PersistedReaction?
+
/** Updates the identifier of a persisted packet. */
suspend fun updateMessageId(d: DataPacket, id: Int)
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
index 814c97b1bd..151a84fe90 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioInterfaceService.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.repository
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@@ -72,6 +73,28 @@ interface RadioSessionAuthority {
runWithSessionLease(session) { block() }
}
+/** Writes raw protocol frames to the currently admitted transport. */
+interface RadioTransportWriter {
+ /** Sends [bytes] when a transport is available; callers that need admission evidence use [trySendToRadio]. */
+ fun sendToRadio(bytes: ByteArray) {
+ if (!trySendToRadio(bytes)) {
+ Logger.withTag("RadioTransportWriter").w {
+ "sendToRadio dropped ${bytes.size} bytes: no active transport accepted the frame"
+ }
+ }
+ }
+
+ /**
+ * Attempts to dispatch [bytes] to the active transport.
+ *
+ * Implementations must return promptly: enqueue transport work internally instead of blocking for I/O or delivery.
+ *
+ * @return `true` when an active transport accepted the bytes for asynchronous delivery, or `false` when no send
+ * could be scheduled or confirmed.
+ */
+ fun trySendToRadio(bytes: ByteArray): Boolean
+}
+
/**
* Interface for the low-level radio interface that handles raw byte communication.
*
@@ -89,7 +112,8 @@ interface RadioSessionAuthority {
*/
interface RadioInterfaceService :
RadioTransportCallback,
- RadioSessionAuthority {
+ RadioSessionAuthority,
+ RadioTransportWriter {
/** The device types supported by this platform's radio interface. */
val supportedDeviceTypes: List<DeviceType>
@@ -155,9 +179,6 @@ interface RadioInterfaceService :
*/
fun resetReceivedBuffer()
- /** Sends a raw byte array to the radio. */
- fun sendToRadio(bytes: ByteArray)
-
/** Initiates the connection to the radio. */
fun connect()
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt
index c0572f83f2..cc2a5a7c5b 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/RadioTransport.kt
@@ -21,8 +21,15 @@ package org.meshtastic.core.repository
* KMP-compatible replacement for the legacy Android-specific IRadioInterface.
*/
interface RadioTransport {
- /** Sends a raw byte array to the radio hardware. */
- fun handleSendToRadio(p: ByteArray)
+ /**
+ * Attempts to hand [p] to this transport for delivery. Implementations must return promptly after any required
+ * synchronous admission checks and enqueue asynchronous I/O internally.
+ *
+ * @return `true` when the transport accepted or scheduled the handoff, or `false` when no send was scheduled.
+ * Acceptance does not confirm that the bytes reached the radio; protocol acknowledgements provide delivery
+ * evidence where required.
+ */
+ fun handleSendToRadio(p: ByteArray): Boolean
/**
* Initializes the transport after construction. Called by the factory once the transport has been fully created.
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AwaitedSendResultTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AwaitedSendResultTest.kt
new file mode 100644
index 0000000000..91af42c746
--- /dev/null
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/AwaitedSendResultTest.kt
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import kotlin.test.Test
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class AwaitedSendResultTest {
+ @Test
+ fun validDispatchPairingPreservesAcceptedMapping() {
+ val result = AwaitedSendResult(status = AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 7L)
+
+ assertTrue(result.accepted)
+ assertTrue(result.dispatched)
+ }
+
+ @Test
+ fun acceptedStatusRequiresDispatch() {
+ assertFailsWith<IllegalArgumentException> { AwaitedSendResult(AwaitedSendStatus.ACCEPTED) }
+ }
+
+ @Test
+ fun rejectedStatusCannotClaimTransportDispatch() {
+ assertFailsWith<IllegalArgumentException> {
+ AwaitedSendResult(AwaitedSendStatus.REJECTED, departureEpochAtDispatch = 7L)
+ }
+ }
+
+ @Test
+ fun radioRejectedStatusRequiresTransportDispatch() {
+ assertFailsWith<IllegalArgumentException> { AwaitedSendResult(AwaitedSendStatus.RADIO_REJECTED) }
+ }
+
+ @Test
+ fun timedOutStatusRequiresTransportDispatch() {
+ assertFailsWith<IllegalArgumentException> { AwaitedSendResult(AwaitedSendStatus.TIMED_OUT) }
+ }
+
+ @Test
+ fun rejectedNonDispatchedResultIsValidAndNotAccepted() {
+ val result = AwaitedSendResult(AwaitedSendStatus.REJECTED)
+
+ assertFalse(result.accepted)
+ assertFalse(result.dispatched)
+ }
+
+ @Test
+ fun transportStoppedMayOccurBeforeOrAfterTransportDispatch() {
+ val beforeDispatch = AwaitedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ val afterDispatch = AwaitedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED, departureEpochAtDispatch = 7L)
+
+ assertFalse(beforeDispatch.accepted)
+ assertFalse(beforeDispatch.dispatched)
+ assertFalse(afterDispatch.accepted)
+ assertTrue(afterDispatch.dispatched)
+ }
+
+ @Test
+ fun sendFailedStatusCannotClaimTransportDispatch() {
+ val result = AwaitedSendResult(AwaitedSendStatus.SEND_FAILED)
+ assertFalse(result.accepted)
+ assertFalse(result.dispatched)
+ assertFailsWith<IllegalArgumentException> {
+ AwaitedSendResult(AwaitedSendStatus.SEND_FAILED, departureEpochAtDispatch = 7L)
+ }
+ }
+
+ @Test
+ fun radioRejectedAndTimedOutResultsAreNotAccepted() {
+ assertFalse(AwaitedSendResult(AwaitedSendStatus.RADIO_REJECTED, 7L).accepted)
+ assertFalse(AwaitedSendResult(AwaitedSendStatus.TIMED_OUT, 7L).accepted)
+ }
+}
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/ConnectionStateHolderTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/ConnectionStateHolderTest.kt
new file mode 100644
index 0000000000..1545e97aa6
--- /dev/null
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/ConnectionStateHolderTest.kt
@@ -0,0 +1,348 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionLifecycle
+import org.meshtastic.core.model.ConnectionState
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+
+class ConnectionStateHolderTest {
+ @Test
+ fun `transitions advance matching epochs exactly once`() {
+ val holder = ConnectionStateHolder()
+
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.Connecting)
+ holder.setConnectionState(ConnectionState.Disconnected)
+ holder.setConnectionState(ConnectionState.Connected)
+
+ assertEquals(ConnectionState.Connected, holder.connectionState.value)
+ assertEquals(4L, holder.connectionLifecycle.value.version)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 2,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ holder.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `device sleep departure records handshake evidence`() {
+ val holder = ConnectionStateHolder()
+
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.DeviceSleep)
+
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.DeviceSleep,
+ ),
+ holder.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `lifecycle flow publishes state and epochs as one correlated snapshot`() {
+ val holder = ConnectionStateHolder()
+
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.Connecting)
+
+ assertEquals(
+ ConnectionLifecycle(
+ version = 2,
+ state = ConnectionState.Connecting,
+ epochs =
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ ),
+ holder.connectionLifecycle.value,
+ )
+ }
+
+ @Test
+ fun `concurrent duplicate transitions advance each lifecycle edge once`() = runTest {
+ val holder = ConnectionStateHolder()
+
+ suspend fun fanOut(state: ConnectionState) = coroutineScope {
+ List(100) { async(Dispatchers.Default) { holder.setConnectionState(state) } }.awaitAll()
+ }
+
+ fanOut(ConnectionState.Connected)
+ fanOut(ConnectionState.Connecting)
+ fanOut(ConnectionState.Connected)
+
+ assertEquals(ConnectionState.Connected, holder.connectionState.value)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 2,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ holder.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `initial epochs reject impossible departure evidence`() {
+ assertFailsWith<IllegalArgumentException> {
+ ConnectionStateHolder(
+ initialEpochs =
+ ConnectionEpochs(
+ departures = 0,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = null,
+ ),
+ )
+ }
+ assertFailsWith<IllegalArgumentException> {
+ ConnectionStateHolder(
+ initialEpochs =
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connected,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `concurrent mixed transitions publish correlated lifecycle snapshots while active`() = runTest {
+ repeat(25) {
+ val holder = ConnectionStateHolder()
+ val snapshotsLock = SynchronizedObject()
+ val snapshots = mutableListOf<ConnectionLifecycle>()
+ val collectorReady = CompletableDeferred<Unit>()
+ val transitionObserved = CompletableDeferred<Unit>()
+ val collector =
+ backgroundScope.launch(Dispatchers.Default) {
+ holder.connectionLifecycle.collect { lifecycle ->
+ synchronized(snapshotsLock) { snapshots += lifecycle }
+ collectorReady.complete(Unit)
+ if (lifecycle.version > 0) transitionObserved.complete(Unit)
+ }
+ }
+ collectorReady.await()
+
+ val states =
+ List(25) {
+ listOf(
+ ConnectionState.Connected,
+ ConnectionState.Connecting,
+ ConnectionState.DeviceSleep,
+ ConnectionState.Disconnected,
+ )
+ }
+ .flatten()
+ coroutineScope {
+ states.map { state -> async(Dispatchers.Default) { holder.setConnectionState(state) } }.awaitAll()
+ }
+ withContext(Dispatchers.Default) { withTimeout(5.seconds) { transitionObserved.await() } }
+ collector.cancelAndJoin()
+
+ val recordedSnapshots = synchronized(snapshotsLock) { snapshots.toList() }
+ assertTrue(recordedSnapshots.any { it.version > 0 }, "collector must observe an active transition")
+ recordedSnapshots.forEach { lifecycle ->
+ val connectedOffset = if (lifecycle.state is ConnectionState.Connected) 1 else 0
+ assertEquals(lifecycle.epochs.departures + connectedOffset, lifecycle.epochs.completedHandshakes)
+ assertTrue(
+ lifecycle.epochs.handshakesAtLastDeparture <= lifecycle.epochs.completedHandshakes,
+ "departure handshake evidence must come from the same lifecycle commit",
+ )
+ }
+
+ val lifecycle = holder.connectionLifecycle.value
+ assertEquals(lifecycle.state, holder.connectionState.value)
+ assertEquals(lifecycle.epochs, holder.connectionEpochs.value)
+ }
+ }
+
+ @Test
+ fun `reset preserves monotonic epochs when no baseline is supplied`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.Disconnected)
+ val epochsBeforeReset = holder.connectionEpochs.value
+
+ holder.reset(state = ConnectionState.DeviceSleep)
+
+ assertEquals(epochsBeforeReset, holder.connectionEpochs.value)
+ assertEquals(ConnectionState.DeviceSleep, holder.connectionState.value)
+ }
+
+ @Test
+ fun `reset into disconnected records a canonical departure`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+
+ holder.reset()
+
+ assertEquals(ConnectionState.Disconnected, holder.connectionState.value)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Disconnected,
+ ),
+ holder.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `reset rejects an explicit epoch baseline that rewinds monotonic counters`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+ holder.setConnectionState(ConnectionState.Disconnected)
+
+ assertFailsWith<IllegalArgumentException> { holder.reset(epochs = ConnectionEpochs()) }
+ }
+
+ @Test
+ fun `explicit reset baseline must include the requested lifecycle transition`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+ val connectedEpochs = holder.connectionEpochs.value
+
+ assertFailsWith<IllegalArgumentException> {
+ holder.reset(state = ConnectionState.Disconnected, epochs = connectedEpochs)
+ }
+ }
+
+ @Test
+ fun `explicit reset baseline must preserve the canonical departure state`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+
+ assertFailsWith<IllegalArgumentException> {
+ holder.reset(
+ state = ConnectionState.Disconnected,
+ epochs =
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `explicit reset baseline must retain its last departure state`() {
+ val holder = ConnectionStateHolder()
+
+ assertFailsWith<IllegalArgumentException> { holder.reset(epochs = ConnectionEpochs(departures = 1)) }
+ }
+
+ @Test
+ fun `constructor rejects negative epoch counters`() {
+ listOf(
+ ConnectionEpochs(departures = -1, lastDepartureState = ConnectionState.Disconnected),
+ ConnectionEpochs(completedHandshakes = -1),
+ ConnectionEpochs(handshakesAtLastDeparture = -1),
+ )
+ .forEach { epochs ->
+ assertFailsWith<IllegalArgumentException> { ConnectionStateHolder(initialEpochs = epochs) }
+ }
+ }
+
+ @Test
+ fun `constructor rejects an initial epoch snapshot without its departure state`() {
+ assertFailsWith<IllegalArgumentException> {
+ ConnectionStateHolder(initialEpochs = ConnectionEpochs(departures = 1))
+ }
+ }
+
+ @Test
+ fun `constructor rejects initial departure evidence beyond completed handshakes`() {
+ assertFailsWith<IllegalArgumentException> {
+ ConnectionStateHolder(
+ initialEpochs =
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 2,
+ lastDepartureState = ConnectionState.Disconnected,
+ ),
+ )
+ }
+ }
+
+ @Test
+ fun `reset restores an explicit state and epoch baseline`() {
+ val holder = ConnectionStateHolder()
+ holder.setConnectionState(ConnectionState.Connected)
+ val versionBeforeReset = holder.connectionLifecycle.value.version
+
+ holder.reset(
+ state = ConnectionState.DeviceSleep,
+ epochs =
+ ConnectionEpochs(
+ departures = 7,
+ completedHandshakes = 11,
+ handshakesAtLastDeparture = 10,
+ lastDepartureState = ConnectionState.DeviceSleep,
+ ),
+ )
+
+ assertEquals(versionBeforeReset + 1, holder.connectionLifecycle.value.version)
+ assertEquals(ConnectionState.DeviceSleep, holder.connectionState.value)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 7,
+ completedHandshakes = 11,
+ handshakesAtLastDeparture = 10,
+ lastDepartureState = ConnectionState.DeviceSleep,
+ ),
+ holder.connectionEpochs.value,
+ )
+ }
+}
diff --git a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt
index 303b8a4ad7..8100c2e49f 100644
--- a/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt
+++ b/core/repository/src/commonTest/kotlin/org/meshtastic/core/repository/RadioTransportTest.kt
@@ -30,8 +30,9 @@ class RadioTransportTest {
val transport =
object : RadioTransport {
- override fun handleSendToRadio(p: ByteArray) {
+ override fun handleSendToRadio(p: ByteArray): Boolean {
sentData = p
+ return true
}
override fun keepAlive() {
@@ -44,10 +45,11 @@ class RadioTransportTest {
}
val testData = byteArrayOf(1, 2, 3)
- transport.handleSendToRadio(testData)
+ val accepted = transport.handleSendToRadio(testData)
transport.keepAlive()
transport.close()
+ assertTrue(accepted)
assertTrue(sentData!!.contentEquals(testData))
assertTrue(keepAliveCalled)
assertTrue(closed)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index f16173afa7..15f772168b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -1270,6 +1270,7 @@
<string name="node_list_help_title">Node List Help</string>
<string name="node_list_long_click_label">Node options</string>
<string name="node_number">Node Number</string>
+ <string name="node_request_send_failed">Couldn't send request. Try again.</string>
<string name="node_restarting">Restarting…</string>
<string name="node_sort_alpha">A-Z</string>
<string name="node_sort_button">Node sorting options</string>
diff --git a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/UiText.kt b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/UiText.kt
index 59442b0bce..0671a392e7 100644
--- a/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/UiText.kt
+++ b/core/resources/src/commonMain/kotlin/org/meshtastic/core/resources/UiText.kt
@@ -18,7 +18,6 @@ package org.meshtastic.core.resources
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
-import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
/**
@@ -74,13 +73,13 @@ sealed class UiText {
val resolvedArgs =
args.map { arg ->
when (arg) {
- is StringResource -> getString(arg)
+ is StringResource -> getStringSuspend(arg)
is UiText -> arg.resolve()
else -> arg
}
}
@Suppress("SpreadOperator")
- getString(res, *resolvedArgs.toTypedArray())
+ getStringSuspend(res, *resolvedArgs.toTypedArray())
}
}
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
index 454a3771a1..68345c86c4 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/AdminControllerImpl.kt
@@ -17,18 +17,31 @@
package org.meshtastic.core.service
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.handledLaunch
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Position
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.AdminEditScope
+import org.meshtastic.core.repository.AwaitedSendStatus
import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.ConnectionStateProvider
+import org.meshtastic.core.repository.EditSettingsTransactionException
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.toFixedPositionAdminMessage
+import org.meshtastic.core.repository.toFixedPositionProto
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -36,21 +49,36 @@ import org.meshtastic.proto.HamParameters
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.OTAMode
import org.meshtastic.proto.User
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Additional window for observing local transport departure after a dispatched commit loses its response. The
+ * surrounding NonCancellable finalization can take longer because [CommandSender.sendAdminAwaitResult] first waits
+ * behind older FIFO entries and then owns its radio-response window.
+ */
+internal val COMMIT_DEPARTURE_TIMEOUT = 5.seconds
+
+internal fun editSettingsBoundaryFailureMessage(boundary: String): String =
+ "Device rejected or timed out while sending edit-settings $boundary"
+
+internal fun editSettingsCommitFailureMessage(status: AwaitedSendStatus, dispatched: Boolean): String =
+ "Device rejected or timed out while sending edit-settings commit (status=$status, dispatched=$dispatched)"
/**
* [AdminController] implementation: local/remote configuration, channels, owner, device lifecycle, and the
* [editSettings] transaction.
*
* Focused collaborator of [RadioControllerImpl]. Builds [AdminMessage] protos directly and delegates to [CommandSender]
- * for transport, mirroring the SDK's `AdminApiImpl` pattern. Config/channel writes use fire-and-forget optimistic local
- * persistence ([handledLaunch]): the device is the source of truth and re-sends its full config on every connection, so
- * persistence is a cache optimization, not a correctness requirement.
+ * for transport, mirroring the SDK's `AdminApiImpl` pattern. Standalone writes may update local caches optimistically;
+ * transactional writes stage their local projections until commit acceptance so failed edits cannot publish settings
+ * the device did not commit. The device remains the source of truth and re-sends its full config on every connection.
*/
@Suppress("TooManyFunctions")
internal class AdminControllerImpl(
private val commandSender: CommandSender,
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
+ private val connectionStateProvider: ConnectionStateProvider,
private val scope: CoroutineScope,
) : AdminController {
@@ -219,31 +247,164 @@ internal class AdminControllerImpl(
// ── Edit Settings (transactional) ───────────────────────────────────────
override suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit) {
- commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
- EditSettingsSession(destNum).block()
- commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
+ val isLocalDestination = destNum == nodeManager.myNodeNum.value
+ requireBeginBoundaryAccepted(destNum)
+
+ // Firmware has no abort boundary. Preserve any block failure, including cancellation, only long enough to
+ // attempt the commit that closes the accepted session; the original failure is restored below. Local cache
+ // projections are staged by the session and become visible only after both the block and commit succeed.
+ val session = EditSettingsSession(destNum, isLocalDestination)
+ val blockResult = runCatching { session.block() }
+ val commitResult = runCatching {
+ withContext(NonCancellable) { requireCommitBoundaryAccepted(destNum, isLocalDestination) }
+ }
+
+ blockResult.exceptionOrNull()?.let { blockFailure ->
+ // Coroutine stack-trace recovery may rebuild the same exception across the NonCancellable boundary.
+ commitResult
+ .exceptionOrNull()
+ ?.takeUnless { it.containsCauseIdentity(blockFailure) }
+ ?.let(blockFailure::addSuppressed)
+ throw blockFailure
+ }
+ commitResult.getOrThrow()
+ session.applyStagedProjections()
+ }
+
+ private fun Throwable.containsCauseIdentity(expected: Throwable): Boolean {
+ val visited = mutableListOf<Throwable>()
+ var current: Throwable? = this
+ var found = false
+ while (current != null) {
+ val candidate = current
+ if (visited.any { it === candidate }) {
+ current = null
+ } else {
+ found = candidate === expected
+ visited += candidate
+ current = candidate.cause.takeUnless { found }
+ }
+ }
+ return found
+ }
+
+ override suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit) {
+ val localNodeNum = nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("Edit settings")
+ editSettings(localNodeNum, block)
}
- override suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit) = editSettings(myNodeNum, block)
+ /** Binds [AdminEditScope] writes to one destination and stages optimistic projections until commit acceptance. */
+ private inner class EditSettingsSession(private val destNum: Int, private val isLocalDestination: Boolean) :
+ AdminEditScope {
+ private val projectionLock = SynchronizedObject()
+ private val stagedProjections = mutableListOf<suspend () -> Unit>()
- /** Binds the [AdminEditScope] operations to a fixed destination, delegating to this controller's set* methods. */
- private inner class EditSettingsSession(private val destNum: Int) : AdminEditScope {
- override suspend fun setOwner(user: User) = setOwner(destNum, user, commandSender.generatePacketId())
+ override suspend fun setOwner(user: User) {
+ commandSender.sendAdmin(destNum, commandSender.generatePacketId()) { AdminMessage(set_owner = user) }
+ stageProjection { nodeManager.handleReceivedUser(destNum, user) }
+ }
- override suspend fun setConfig(config: Config) = setConfig(destNum, config, commandSender.generatePacketId())
+ override suspend fun setConfig(config: Config) {
+ commandSender.sendAdmin(destNum, commandSender.generatePacketId()) { AdminMessage(set_config = config) }
+ if (isLocalDestination) stageProjection { radioConfigRepository.setLocalConfig(config) }
+ }
- override suspend fun setModuleConfig(config: ModuleConfig) =
- setModuleConfig(destNum, config, commandSender.generatePacketId())
+ override suspend fun setModuleConfig(config: ModuleConfig) {
+ commandSender.sendAdmin(destNum, commandSender.generatePacketId()) {
+ AdminMessage(set_module_config = config)
+ }
+ if (isLocalDestination) {
+ stageProjection {
+ config.statusmessage?.let { status -> nodeManager.updateNodeStatus(destNum, status.node_status) }
+ radioConfigRepository.setLocalModuleConfig(config)
+ }
+ }
+ }
- // Unlike the one-shot setRemoteChannel, a transactional channel write does NOT mirror to the local cache per
- // slot: importChannelSet owns the cache and writes it once after commit (replaceAllSettings), so an import
- // interrupted before commit leaves the local channel cache untouched. (Firmware still writes each set_channel
- // into its in-memory channel table on arrival; only disk persist/reload/reboot is deferred to commit.)
+ // Unlike one-shot writes, a transaction can replace several slots. This scope cannot infer the complete target
+ // set, so the operation adding channel writes must reconcile that set after the transaction; mirroring slots
+ // here could expose a partial cache if a later write or commit fails.
override suspend fun setChannel(channel: Channel) =
- commandSender.sendAdmin(destNum) { AdminMessage(set_channel = channel) }
+ commandSender.sendAdmin(destNum, commandSender.generatePacketId()) { AdminMessage(set_channel = channel) }
+
+ override suspend fun setFixedPosition(position: Position) {
+ val removesFixedPosition = position.isFixedPositionRemoval()
+ val projectionNodeNum =
+ if (removesFixedPosition) {
+ null
+ } else {
+ nodeManager.myNodeNum.value ?: throw LocalNodeUnavailableException("Fixed position")
+ }
+ commandSender.sendAdmin(destNum, commandSender.generatePacketId()) {
+ position.toFixedPositionAdminMessage()
+ }
+ if (projectionNodeNum != null) {
+ val protoPosition = position.toFixedPositionProto()
+ stageProjection {
+ nodeManager.handleReceivedPosition(destNum, projectionNodeNum, protoPosition, nowMillis)
+ }
+ }
+ }
+
+ private fun stageProjection(block: suspend () -> Unit) {
+ synchronized(projectionLock) { stagedProjections += block }
+ }
- override suspend fun setFixedPosition(position: Position) =
- this@AdminControllerImpl.setFixedPosition(destNum, position)
+ suspend fun applyStagedProjections() = withContext(NonCancellable) {
+ val projections =
+ synchronized(projectionLock) { stagedProjections.toList().also { stagedProjections.clear() } }
+ projections.forEach { projection -> applyProjection(projection) }
+ }
+
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun applyProjection(projection: suspend () -> Unit) {
+ try {
+ projection()
+ } catch (e: Exception) {
+ Logger.w(e) { "Local edit-settings projection failed after device commit" }
+ }
+ }
+ }
+
+ /** Requires the begin boundary to be admitted before any transactional settings writes are issued. */
+ private suspend fun requireBeginBoundaryAccepted(destNum: Int) {
+ if (!commandSender.sendAdminAwait(destNum) { AdminMessage(begin_edit_settings = true) }) {
+ throw EditSettingsTransactionException(editSettingsBoundaryFailureMessage("begin"))
+ }
+ }
+
+ /**
+ * Applies commit back-pressure. Ordinary writes are already queued FIFO by [CommandSender], so waiting for the
+ * commit's queue acceptance keeps the caller suspended until the sender has processed every preceding write. This
+ * prevents a rebooting transport write from overtaking an uncommitted settings transaction.
+ */
+ private suspend fun requireCommitBoundaryAccepted(destNum: Int, isLocalDestination: Boolean) {
+ val result = commandSender.sendAdminAwaitResult(destNum) { AdminMessage(commit_edit_settings = true) }
+ val uncertainAfterDispatch =
+ isLocalDestination &&
+ result.dispatched &&
+ result.departureEpochAtDispatch != null &&
+ (result.status == AwaitedSendStatus.TRANSPORT_STOPPED || result.status == AwaitedSendStatus.TIMED_OUT)
+ val departedLifecycle =
+ if (uncertainAfterDispatch) {
+ withTimeoutOrNull(COMMIT_DEPARTURE_TIMEOUT) {
+ connectionStateProvider.connectionLifecycle.first {
+ it.epochs.departures > checkNotNull(result.departureEpochAtDispatch)
+ }
+ }
+ } else {
+ null
+ }
+ // A dispatched local commit is durable once a post-dispatch departure is observed. Firmware may move the
+ // connection through Disconnected, Connecting, or DeviceSleep while rebooting.
+ val committedBeforeLocalDeparture = uncertainAfterDispatch && departedLifecycle != null
+
+ if (!result.accepted && !committedBeforeLocalDeparture) {
+ throw EditSettingsTransactionException(editSettingsCommitFailureMessage(result.status, result.dispatched))
+ }
+ if (committedBeforeLocalDeparture) {
+ Logger.i { "Edit-settings commit dispatched before expected local transport departure" }
+ }
}
private companion object {
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
index ced2ed140a..8ce73d3a2a 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
@@ -100,7 +100,7 @@ internal class MessagingControllerImpl(
rssi = null,
hopsAway = 0,
packetId = dataPacket.id,
- status = MessageStatus.QUEUED,
+ status = dataPacket.status ?: MessageStatus.QUEUED,
to = destId,
channel = channel,
),
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt
index e1418445db..da10a6c748 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/NodeControllerImpl.kt
@@ -16,11 +16,13 @@
*/
package org.meshtastic.core.service
+import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeController
import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.proto.AdminMessage
@@ -74,6 +76,12 @@ internal class NodeControllerImpl(
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
val myNum = nodeManager.myNodeNum.value ?: return
- commandSender.sendAdmin(myNum, packetId) { AdminMessage(remove_by_nodenum = nodeNum) }
+ try {
+ commandSender.sendAdmin(myNum, packetId) { AdminMessage(remove_by_nodenum = nodeNum) }
+ } catch (e: PacketQueueRejectedException) {
+ // Node removal has always been local-first and is allowed while disconnected. Preserve that contract when
+ // the connected transport is transitioning and cannot admit the best-effort radio cleanup command.
+ Logger.w(e) { "Remove-node admin command for $nodeNum was not admitted; local removal retained" }
+ }
}
}
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
index 8b353b666a..e4405b7e93 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt
@@ -31,6 +31,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.database.DatabaseManager
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionLifecycle
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.AdminController
import org.meshtastic.core.repository.CommandSender
@@ -95,7 +97,13 @@ class RadioControllerImpl(
scope: CoroutineScope,
private val onDeviceAddressChanged: (() -> Unit)? = null,
) : RadioController,
- AdminController by AdminControllerImpl(commandSender, nodeManager, radioConfigRepository, scope),
+ AdminController by AdminControllerImpl(
+ commandSender = commandSender,
+ nodeManager = nodeManager,
+ radioConfigRepository = radioConfigRepository,
+ connectionStateProvider = serviceRepository,
+ scope = scope,
+ ),
MessagingController by MessagingControllerImpl(
commandSender,
nodeManager,
@@ -195,9 +203,15 @@ class RadioControllerImpl(
// ── Connection State ────────────────────────────────────────────────────
+ override val connectionLifecycle: StateFlow<ConnectionLifecycle>
+ get() = serviceRepository.connectionLifecycle
+
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState
+ override val connectionEpochs: StateFlow<ConnectionEpochs>
+ get() = serviceRepository.connectionEpochs
+
override val clientNotification: StateFlow<ClientNotification?>
get() = serviceRepository.clientNotification
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
index 40cd838475..fb4d4a60af 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/ServiceRepositoryImpl.kt
@@ -27,6 +27,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.repository.ConnectionStateHolder
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
@@ -42,13 +43,13 @@ import org.meshtastic.proto.MeshPacket
open class ServiceRepositoryImpl : ServiceRepository {
// Canonical app-level connection state — written exclusively by MeshConnectionManager.
- private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
- override val connectionState: StateFlow<ConnectionState>
- get() = _connectionState
+ private val connectionStateHolder = ConnectionStateHolder()
+ override val connectionLifecycle = connectionStateHolder.connectionLifecycle
+ override val connectionState = connectionStateHolder.connectionState
+ override val connectionEpochs = connectionStateHolder.connectionEpochs
- override fun setConnectionState(connectionState: ConnectionState) {
- _connectionState.value = connectionState
- }
+ override fun setConnectionState(connectionState: ConnectionState) =
+ connectionStateHolder.setConnectionState(connectionState)
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?>
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index 4d054deff2..de41730fdb 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -60,9 +60,9 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.di.PROCESS_LIFECYCLE
-import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
@@ -98,6 +98,8 @@ data class RadioTransportSession(val generation: Long, val address: String) {
override fun toString(): String = "RadioTransportSession(generation=$generation, address=...)"
}
+private class SessionOperationState(var admittedOperations: Int = 0, var drainWaiter: CompletableDeferred<Unit>? = null)
+
private data class SelectedSerialPresence(val key: String?, val present: Boolean)
private data class UsbRecoverySnapshot(val presence: SelectedSerialPresence, val state: ConnectionState)
@@ -158,7 +160,7 @@ private fun TransportDisconnectReason.toConnectionErrorMessage(): String = when
* hardware state observability (BLE/Network toggles). Delegates the actual raw byte transport mapping to a
* platform-specific [RadioTransportFactory].
*/
-@Suppress("LongParameterList", "TooManyFunctions")
+@Suppress("LargeClass", "LongParameterList", "TooManyFunctions")
@Single
class SharedRadioInterfaceService(
private val dispatchers: CoroutineDispatchers,
@@ -212,11 +214,8 @@ class SharedRadioInterfaceService(
/** Guarded by [sessionCallbackLock]. New work is rejected immediately when teardown closes this gate. */
private var sessionAdmissionOpen = false
- /** Number of suspend operations admitted for [activeTransportSession], guarded by [sessionCallbackLock]. */
- private var admittedSessionOperations = 0
-
- /** Completed by the last admitted operation after teardown closes admission. Guarded by [sessionCallbackLock]. */
- private var sessionDrainWaiter: CompletableDeferred<Unit>? = null
+ /** Per-generation operation ownership, guarded by [sessionCallbackLock]. */
+ private val sessionOperationStates = mutableMapOf<Long, SessionOperationState>()
/** Preserves FIFO ordering for handshake work without blocking independently leased packet side effects. */
private val sessionOperationMutex = Mutex()
@@ -241,7 +240,7 @@ class SharedRadioInterfaceService(
if (!sessionAdmissionOpen || active?.context != session) {
null
} else {
- admittedSessionOperations++
+ checkNotNull(sessionOperationStates[active.generation]).admittedOperations++
active
}
} ?: return false
@@ -258,20 +257,7 @@ class SharedRadioInterfaceService(
block(lease)
return true
} finally {
- val drainWaiter =
- synchronized(sessionCallbackLock) {
- check(activeTransportSession === admittedSession) {
- "Session changed before an admitted operation released its lease"
- }
- check(admittedSessionOperations > 0) { "Session operation count underflow" }
- admittedSessionOperations--
- if (admittedSessionOperations == 0) {
- sessionDrainWaiter.also { sessionDrainWaiter = null }
- } else {
- null
- }
- }
- drainWaiter?.complete(Unit)
+ releaseSessionOperation(admittedSession)
}
}
@@ -294,6 +280,81 @@ class SharedRadioInterfaceService(
}
}
+ private fun releaseSessionOperation(admittedSession: RadioTransportSession) {
+ val drainWaiter =
+ synchronized(sessionCallbackLock) {
+ val state = sessionOperationStates[admittedSession.generation]
+ if (state == null) {
+ Logger.e { "Session operation released after generation ${admittedSession.generation} was revoked" }
+ return@synchronized null
+ }
+ if (state.admittedOperations <= 0) {
+ Logger.e { "Session operation count underflow for generation ${admittedSession.generation}" }
+ return@synchronized state.drainWaiter.also { state.drainWaiter = null }
+ }
+ state.admittedOperations--
+ if (state.admittedOperations == 0) {
+ state.drainWaiter.also { state.drainWaiter = null }
+ } else {
+ null
+ }
+ }
+ drainWaiter?.complete(Unit)
+ }
+
+ /** Removes a drained session's operation state without letting a diagnostic invariant failure block teardown. */
+ private fun removeDrainedSessionStateLocked(session: RadioTransportSession): CompletableDeferred<Unit>? {
+ val state = sessionOperationStates.remove(session.generation)
+ if (state == null) {
+ Logger.e { "Session generation ${session.generation} lost its operation state during drain" }
+ return null
+ }
+ if (state.admittedOperations != 0) {
+ Logger.e {
+ "Session generation ${session.generation} revoked with " +
+ "${state.admittedOperations} admitted operation(s) still outstanding"
+ }
+ }
+ return state.drainWaiter?.also {
+ Logger.e { "Session generation ${session.generation} retained a drain waiter" }
+ state.drainWaiter = null
+ }
+ }
+
+ private sealed interface TransportSendAdmission {
+ data class Admitted(val session: RadioTransportSession, val transport: RadioTransport) : TransportSendAdmission
+
+ data object AdmissionClosed : TransportSendAdmission
+
+ data object NoActiveSession : TransportSendAdmission
+
+ data object NoTransport : TransportSendAdmission
+ }
+
+ /** Admits one synchronous transport handoff under the same gate drained by [revokeTransportSession]. */
+ private fun admitTransportSend(): TransportSendAdmission = synchronized(sessionCallbackLock) {
+ val session = activeTransportSession
+ val transport = radioTransport
+ when {
+ !sessionAdmissionOpen -> TransportSendAdmission.AdmissionClosed
+
+ session == null -> TransportSendAdmission.NoActiveSession
+
+ transport == null -> TransportSendAdmission.NoTransport
+
+ else -> {
+ val state = sessionOperationStates[session.generation]
+ if (state == null) {
+ Logger.e { "Session generation ${session.generation} has no operation state at send admission" }
+ TransportSendAdmission.NoActiveSession
+ } else {
+ state.admittedOperations++
+ TransportSendAdmission.Admitted(session = session, transport = transport)
+ }
+ }
+ }
+ }
+
/** Runs a callback only while [session] still owns admission, atomically with session teardown. */
private inline fun runIfTransportSessionActive(session: RadioTransportSession, block: () -> Unit): Boolean =
synchronized(sessionCallbackLock) {
@@ -314,10 +375,14 @@ class SharedRadioInterfaceService(
synchronized(sessionCallbackLock) {
if (activeTransportSession !== session) return@synchronized null
sessionAdmissionOpen = false
- if (admittedSessionOperations == 0) {
+ val state = sessionOperationStates[session.generation]
+ if (state == null) {
+ Logger.e { "Session generation ${session.generation} has no operation state at revocation" }
+ null
+ } else if (state.admittedOperations == 0) {
null
} else {
- sessionDrainWaiter ?: CompletableDeferred<Unit>().also { sessionDrainWaiter = it }
+ state.drainWaiter ?: CompletableDeferred<Unit>().also { state.drainWaiter = it }
}
}
if (drainWaiter != null) {
@@ -328,21 +393,28 @@ class SharedRadioInterfaceService(
var waitedMillis = 0L
while (withTimeoutOrNull(DRAIN_WAIT_LOG_INTERVAL_MILLIS) { drainWaiter.await() } == null) {
waitedMillis += DRAIN_WAIT_LOG_INTERVAL_MILLIS
- val outstanding = synchronized(sessionCallbackLock) { admittedSessionOperations }
+ val outstanding =
+ synchronized(sessionCallbackLock) {
+ sessionOperationStates[session.generation]?.admittedOperations ?: 0
+ }
Logger.e {
"Transport teardown blocked ${waitedMillis}ms waiting for $outstanding admitted session " +
"operation(s) to release (generation=${session.generation})"
}
}
}
- synchronized(sessionCallbackLock) {
- if (activeTransportSession === session) {
- check(admittedSessionOperations == 0) { "Session revoked before admitted operations drained" }
- activeTransportSession = null
- _activeSession.value = null
- sessionDrainWaiter = null
+ val retainedDrainWaiter =
+ synchronized(sessionCallbackLock) {
+ if (activeTransportSession === session) {
+ val waiter = removeDrainedSessionStateLocked(session)
+ activeTransportSession = null
+ _activeSession.value = null
+ waiter
+ } else {
+ null
+ }
}
- }
+ retainedDrainWaiter?.complete(Unit)
}
}
@@ -372,17 +444,11 @@ class SharedRadioInterfaceService(
get() = _serviceScope
private var _serviceScope = CoroutineScope(dispatchers.io + SupervisorJob())
- private var radioTransport: RadioTransport? = null
+
+ @Volatile private var radioTransport: RadioTransport? = null
private var runningTransportId: InterfaceId? = null
private var isStarted = false
- /**
- * Set while [stopTransportLocked] is draining the polite disconnect frame. [sendToRadio] checks this so any late
- * traffic submitted after we've announced disconnection is dropped rather than racing in front of the firmware-side
- * link teardown.
- */
- @Volatile private var isStopping = false
-
/**
* True while an explicit connection lifecycle is active (set by [connect]/[setDeviceAddress], cleared by
* [disconnect]). The hardware ([bluetoothRepository.state]) and network ([networkRepository.networkAvailable])
@@ -806,9 +872,10 @@ class SharedRadioInterfaceService(
val generation = sessionGenerationCounter.incrementAndGet()
val session = RadioTransportSession(generation = generation, address = address)
synchronized(sessionCallbackLock) {
- check(activeTransportSession == null && admittedSessionOperations == 0) {
+ check(activeTransportSession == null && sessionOperationStates.isEmpty()) {
"Cannot admit a transport while the previous session is still draining"
}
+ sessionOperationStates[generation] = SessionOperationState()
activeTransportSession = session
sessionAdmissionOpen = true
_activeSession.value = session.context
@@ -832,7 +899,31 @@ class SharedRadioInterfaceService(
_connectionState.value = connectionStateBeforeStart
throw failure
}
- radioTransport = newTransport
+ val published =
+ synchronized(sessionCallbackLock) {
+ if (activeTransportSession !== session || !sessionAdmissionOpen) {
+ // A replaced or cleared token has no revoker left to reclaim this generation's operation state.
+ // A closed gate with a matching token means revokeTransportSession is mid-flight; it owns removal
+ // inside its NonCancellable block, so do not remove the entry here.
+ if (activeTransportSession !== session) sessionOperationStates.remove(generation)
+ false
+ } else {
+ radioTransport = newTransport
+ true
+ }
+ }
+ if (!published) {
+ // Revocation already closed this session's admission and owns the canonical lifecycle rollback. Do not
+ // restore connectionStateBeforeStart here; that would overwrite the newer teardown state.
+ val publicationFailure =
+ IllegalStateException("Transport session was revoked before its transport could be published")
+ try {
+ withContext(NonCancellable) { newTransport.close() }
+ } catch (closeFailure: Exception) {
+ publicationFailure.addSuppressed(closeFailure)
+ }
+ throw publicationFailure
+ }
runningTransportId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
isStarted = true
startHeartbeat()
@@ -857,13 +948,14 @@ class SharedRadioInterfaceService(
// Reject queued callbacks and new suspend work immediately, then drain existing leases before admitting a
// replacement generation or closing the old transport.
revokeTransportSession(currentSession)
+ heartbeatJob?.cancel()
+ heartbeatJob = null
Logger.i { "Stopping transport $currentTransport" }
// Best-effort polite goodbye: tell the firmware we're disconnecting on purpose so it can
// tear down its side of the link cleanly instead of relying on timeouts / hardware events.
- // Flip isStopping before sending so any concurrent sendToRadio() drops incoming traffic —
- // we don't want normal packets racing behind the disconnect frame. Skip only when already
- // Disconnected; firmware can still consume the goodbye while handshaking or sleeping, so
- // it's worth sending in every other state. The send is fire-and-forget through the
+ // Session admission is already revoked, so normal packets cannot race behind this direct transport write.
+ // Skip only when already Disconnected; firmware can still consume the goodbye while handshaking or sleeping,
+ // so it's worth sending in every other state. The send is fire-and-forget through the
// transport's own scope; the drain delay gives async transports a window to flush before
// close() cancels their write scope. BLE's retry path backs off 500ms, so this window
// also covers one retry on flaky GATT links.
@@ -873,7 +965,6 @@ class SharedRadioInterfaceService(
currentTransport != null &&
_connectionState.value != ConnectionState.Disconnected
) {
- isStopping = true
ignoreExceptionSuspend {
currentTransport.handleSendToRadio(ToRadio(disconnect = true).encode())
delay(POLITE_DISCONNECT_DRAIN_MS)
@@ -883,7 +974,6 @@ class SharedRadioInterfaceService(
isStarted = false
radioTransport = null
runningTransportId = null
- isStopping = false
try {
currentTransport?.close()
} finally {
@@ -986,33 +1076,64 @@ class SharedRadioInterfaceService(
}
fun keepAlive(now: Long = now()) {
- if (now - lastHeartbeatMillis > HEARTBEAT_INTERVAL_MILLIS) {
- radioTransport?.keepAlive()
- lastHeartbeatMillis = now
+ if (now - lastHeartbeatMillis < HEARTBEAT_INTERVAL_MILLIS) return
+
+ when (val admission = admitTransportSend()) {
+ is TransportSendAdmission.Admitted -> {
+ if (keepAliveThroughAdmittedTransport(admission)) lastHeartbeatMillis = now
+ }
+
+ TransportSendAdmission.AdmissionClosed,
+ TransportSendAdmission.NoActiveSession,
+ ->
+ Logger.d { "keepAlive: no admitted transport session, dropping heartbeat" }
+
+ TransportSendAdmission.NoTransport ->
+ Logger.d { "keepAlive: admitted session has no radio transport, dropping heartbeat" }
}
}
- override fun sendToRadio(bytes: ByteArray) {
- if (isStopping) {
- Logger.d { "sendToRadio: transport stopping, dropping ${bytes.size} bytes" }
- return
+ private fun keepAliveThroughAdmittedTransport(admission: TransportSendAdmission.Admitted): Boolean = try {
+ safeCatching { admission.transport.keepAlive() }
+ .onFailure { Logger.w(it) { "keepAlive: active transport rejected heartbeat" } }
+ .isSuccess
+ } finally {
+ releaseSessionOperation(admission.session)
+ }
+
+ override fun trySendToRadio(bytes: ByteArray): Boolean =
+ // Admission and teardown share one session gate. Once accepted, teardown drains the synchronous handoff before
+ // revoking the session; transport implementations still own their asynchronous delivery outcome.
+ when (val admission = admitTransportSend()) {
+ is TransportSendAdmission.Admitted -> sendThroughAdmittedTransport(admission, bytes)
+
+ TransportSendAdmission.AdmissionClosed,
+ TransportSendAdmission.NoActiveSession,
+ -> {
+ Logger.d { "trySendToRadio: no admitted transport session, dropping ${bytes.size} bytes" }
+ false
+ }
+
+ TransportSendAdmission.NoTransport -> {
+ Logger.w { "trySendToRadio: admitted session has no radio transport, dropping ${bytes.size} bytes" }
+ false
+ }
}
- // Snapshot the transport to avoid calling handleSendToRadio on a null reference.
- // There is still a benign race: stopTransportLocked() may cancel _serviceScope
- // between the null-check and the launch, causing the coroutine to be silently
- // dropped. This is acceptable — if the transport is shutting down, dropping the
- // send is the correct behavior.
- val currentTransport =
- radioTransport
- ?: run {
- Logger.w { "sendToRadio: no active radio transport, dropping ${bytes.size} bytes" }
- return
- }
- _serviceScope.handledLaunch {
- currentTransport.handleSendToRadio(bytes)
- _meshActivity.tryEmit(MeshActivity.Send)
+
+ private fun sendThroughAdmittedTransport(admission: TransportSendAdmission.Admitted, bytes: ByteArray): Boolean =
+ try {
+ val sent =
+ safeCatching { admission.transport.handleSendToRadio(bytes) }
+ .onFailure { Logger.w(it) { "trySendToRadio: active transport rejected ${bytes.size} bytes" } }
+ .getOrDefault(false)
+ if (sent) {
+ safeCatching { _meshActivity.tryEmit(MeshActivity.Send) }
+ .onFailure { Logger.w(it) { "trySendToRadio: failed to publish mesh activity" } }
+ }
+ sent
+ } finally {
+ releaseSessionOperation(admission.session)
}
- }
@Suppress("TooGenericExceptionCaught")
override fun handleFromRadio(bytes: ByteArray) {
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
index e31c335e93..685695eb28 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt
@@ -23,6 +23,7 @@ import dev.mokkery.answering.throws
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
+import dev.mokkery.matcher.capture.capture
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.atLeast
@@ -31,18 +32,28 @@ import dev.mokkery.verifySuspend
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.Reaction
+import org.meshtastic.core.repository.AwaitedSendResult
+import org.meshtastic.core.repository.AwaitedSendStatus
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.ConnectionIdentity
+import org.meshtastic.core.repository.EditSettingsTransactionException
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -50,6 +61,7 @@ import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
@@ -65,11 +77,13 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.HamParameters
import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertSame
import kotlin.test.assertTrue
@@ -164,6 +178,37 @@ class RadioControllerImplTest {
)
}
+ private data class CommitBoundaryFixture(
+ val controller: RadioControllerImpl,
+ val serviceRepository: ServiceRepositoryImpl,
+ )
+
+ private fun createCommitBoundaryFixture(
+ scope: CoroutineScope,
+ myNodeNum: Int? = 1234,
+ beforeCommitDispatch: (ServiceRepositoryImpl) -> Unit = {},
+ commitResult: (ServiceRepositoryImpl) -> AwaitedSendResult,
+ ): CommitBoundaryFixture {
+ val serviceRepository = ServiceRepositoryImpl()
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ val controller = createController(scope = scope, myNodeNum = myNodeNum, serviceRepository = serviceRepository)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } calls
+ {
+ beforeCommitDispatch(serviceRepository)
+ // Capture after the pre-dispatch hook; commitResult then models events that happen after admission.
+ val departureEpochAtDispatch = serviceRepository.connectionLifecycle.value.epochs.departures
+ commitResult(serviceRepository).let { result ->
+ if (result.dispatched) {
+ result.copy(departureEpochAtDispatch = departureEpochAtDispatch)
+ } else {
+ result
+ }
+ }
+ }
+ return CommitBoundaryFixture(controller, serviceRepository)
+ }
+
@Test
fun staleAddressIdentityCannotAssociateSelectedTransport() = runTest {
val selectedAddress = MutableStateFlow<String?>("tcp:new")
@@ -353,6 +398,45 @@ class RadioControllerImplTest {
verifySuspend { dataHandler.rememberDataPacket(packet, 456, false) }
}
+ @Test
+ fun sendMessageDoesNotPersistWhenQueueRejects() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 456)
+ val packet = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 1, text = "ping")
+ val rejection = PacketQueueRejectedException("Data packet")
+ everySuspend { commandSender.sendData(packet) } throws rejection
+
+ val failure = assertFailsWith<PacketQueueRejectedException> { controller.sendMessage(packet) }
+
+ assertSame(rejection, failure)
+ verifySuspend(exactly(0)) { dataHandler.rememberDataPacket(any(), any(), any()) }
+ }
+
+ @Test
+ fun localChannelDoesNotPersistWhenQueueRejects() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 456)
+ val channel = Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "Primary"))
+ val rejection = PacketQueueRejectedException("Admin command")
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } throws rejection
+
+ val failure = assertFailsWith<PacketQueueRejectedException> { controller.setLocalChannel(channel) }
+
+ assertSame(rejection, failure)
+ verifySuspend(exactly(0)) { radioConfigRepository.updateChannelSettings(any()) }
+ }
+
+ @Test
+ fun fixedPositionPropagatesQueueRejection() = runTest {
+ val controller = createController(scope = backgroundScope)
+ val position = Position(latitude = 1.0, longitude = 2.0, altitude = 3)
+ val rejection = PacketQueueRejectedException("Fixed position")
+ everySuspend { commandSender.setFixedPosition(123, position) } throws rejection
+
+ val failure = assertFailsWith<PacketQueueRejectedException> { controller.setFixedPosition(123, position) }
+
+ assertSame(rejection, failure)
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ }
+
@Test
fun sendSharedContactCallsCommandSenderAdminAwait() = runTest {
val controller = createController(scope = backgroundScope)
@@ -585,12 +669,60 @@ class RadioControllerImplTest {
val node = Node(num = 1234, user = user)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(1234 to node)
every { nodeManager.getMyId() } returns "!abcd1234"
+ // Production CommandSenderImpl.sendData stamps QUEUED on successful queue admission. DataPacket.status is
+ // nullable but defaults to UNKNOWN, so the default value does not exercise the controller's null fallback.
+ // Mirror the production stamp here so this assertion exercises the realistic happy-path contract; the explicit
+ // ERROR-stamping path is covered by sendReactionPersistsStatusStampedByCommandSender.
+ everySuspend { commandSender.sendData(any()) } calls
+ {
+ (it.args[0] as DataPacket).status = MessageStatus.QUEUED
+ }
+
+ val reactions = mutableListOf<Reaction>()
+ everySuspend { packetRepository.insertReaction(capture(reactions), any()) } returns Unit
controller.sendReaction(emoji = "👍", replyId = 42, contactKey = "0!dest5678")
- // Reaction must be persisted (not fire-and-forget)
+ // Reaction must be persisted (not fire-and-forget) with the queue-admission QUEUED status.
verifySuspend { commandSender.sendData(any()) }
- verifySuspend { packetRepository.insertReaction(any(), any()) }
+ assertEquals(MessageStatus.QUEUED, reactions.single().status)
+ }
+
+ @Test
+ fun sendReactionDoesNotPersistWhenQueueRejects() = runTest {
+ val controller = createController(scope = backgroundScope)
+ val user = User(id = "!abcd1234", long_name = "Test", short_name = "T")
+ every { nodeManager.nodeDBbyNodeNum } returns mapOf(1234 to Node(num = 1234, user = user))
+ every { nodeManager.getMyId() } returns "!abcd1234"
+ val rejection = PacketQueueRejectedException("Reaction")
+ everySuspend { commandSender.sendData(any()) } calls
+ {
+ (it.args[0] as DataPacket).status = MessageStatus.ERROR
+ throw rejection
+ }
+
+ val failure =
+ assertFailsWith<PacketQueueRejectedException> {
+ controller.sendReaction(emoji = "👍", replyId = 42, contactKey = "0!dest5678")
+ }
+
+ assertSame(rejection, failure)
+ verifySuspend(exactly(0)) { packetRepository.insertReaction(any(), any()) }
+ }
+
+ @Test
+ fun sendReactionFallsBackToQueuedWhenSenderLeavesStatusNull() = runTest {
+ val controller = createController(scope = backgroundScope)
+ val user = User(id = "!abcd1234", long_name = "Test", short_name = "T")
+ every { nodeManager.nodeDBbyNodeNum } returns mapOf(1234 to Node(num = 1234, user = user))
+ every { nodeManager.getMyId() } returns "!abcd1234"
+ everySuspend { commandSender.sendData(any()) } calls { (it.args[0] as DataPacket).status = null }
+ val reactions = mutableListOf<Reaction>()
+ everySuspend { packetRepository.insertReaction(capture(reactions), any()) } returns Unit
+
+ controller.sendReaction(emoji = "👍", replyId = 42, contactKey = "0!dest5678")
+
+ assertEquals(MessageStatus.QUEUED, reactions.single().status)
}
@Test
@@ -664,6 +796,18 @@ class RadioControllerImplTest {
verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
}
+ @Test
+ fun removeByNodenumRetainsLocalRemovalWhenAdminQueueRejects() = runTest {
+ val controller = createController(scope = backgroundScope)
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } throws
+ PacketQueueRejectedException("Remove node")
+
+ controller.removeByNodenum(packetId = 1, nodeNum = 55)
+
+ verifySuspend { nodeManager.removeByNodenum(55) }
+ verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
+ }
+
@Test
fun removeByNodenumRemovesLocallyEvenWhenDisconnected() = runTest {
val controller = createController(scope = backgroundScope, myNodeNum = null)
@@ -739,9 +883,16 @@ class RadioControllerImplTest {
verifySuspend { commandSender.sendAdmin(any(), any(), any(), any()) }
}
+ private fun dispatchedSendResult(status: AwaitedSendStatus, departureEpochAtDispatch: Long = 0L) =
+ AwaitedSendResult(status, departureEpochAtDispatch = departureEpochAtDispatch)
+
+ private fun acceptedSendResult() = dispatchedSendResult(AwaitedSendStatus.ACCEPTED)
+
@Test
fun editLocalSettingsChannelWritesDoNotMirrorToLocalCache() = runTest {
val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
controller.editLocalSettings {
setChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "A")))
@@ -749,16 +900,607 @@ class RadioControllerImplTest {
}
advanceUntilIdle()
- // Exactly 4 admin packets: begin + 2 channel writes + commit. The tight count also catches a duplicated
- // begin/commit or an accidental double-write per channel.
- verifySuspend(exactly(4)) { commandSender.sendAdmin(any(), any(), any(), any()) }
- // A transactional channel write must NOT eagerly mirror to the local cache the way one-shot
- // setRemoteChannel does for the local node. importChannelSet owns the cache and writes it once after commit
- // (replaceAllSettings), so an interrupted import can't leave partial channels cached. A regression to
- // per-slot mirroring inside the session would make this call count non-zero.
+ // Exactly four packets: awaited begin + 2 channel writes + awaited commit. The tight count also catches a
+ // duplicated boundary or an accidental double-write per channel.
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ verifySuspend(exactly(2)) { commandSender.sendAdmin(any(), any(), any(), any()) }
+ // A transactional channel write must not eagerly mirror to the local cache like a one-shot setRemoteChannel.
+ // The batch operation knows the complete target set and must reconcile it after the transaction; per-slot
+ // mirroring here could expose a partial cache when a later write or commit fails.
verifySuspend(exactly(0)) { radioConfigRepository.updateChannelSettings(any()) }
}
+ @Test
+ fun editSettingsAwaitsBoundariesAroundOrderedWrites() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val sentMessages = mutableListOf<AdminMessage>()
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ sentMessages += (it.args[3] as () -> AdminMessage)()
+ true
+ }
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ sentMessages += (it.args[3] as () -> AdminMessage)()
+ acceptedSendResult()
+ }
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ sentMessages += (it.args[3] as () -> AdminMessage)()
+ }
+
+ controller.editLocalSettings {
+ setChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "Primary")))
+ setChannel(
+ Channel(index = 1, role = Channel.Role.SECONDARY, settings = ChannelSettings(name = "Secondary")),
+ )
+ }
+
+ assertEquals(4, sentMessages.size)
+ assertEquals(true, sentMessages[0].begin_edit_settings)
+ assertEquals(0, sentMessages[1].set_channel?.index)
+ assertEquals(1, sentMessages[2].set_channel?.index)
+ assertEquals(true, sentMessages[3].commit_edit_settings)
+ }
+
+ @Test
+ fun editSettingsAppliesStagedLocalProjectionsOnlyAfterCommitAcceptance() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val commitStarted = CompletableDeferred<Unit>()
+ val releaseCommit = CompletableDeferred<Unit>()
+ val user = User(id = "!000004d2", long_name = "Committed owner")
+ val config = Config(device = Config.DeviceConfig())
+ val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Ready"))
+ val fixedPosition = Position(latitude = 47.6, longitude = -122.3, altitude = 42)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } calls
+ {
+ commitStarted.complete(Unit)
+ releaseCommit.await()
+ acceptedSendResult()
+ }
+
+ val editJob = launch {
+ controller.editLocalSettings {
+ setOwner(user)
+ setConfig(config)
+ setModuleConfig(moduleConfig)
+ setFixedPosition(fixedPosition)
+ }
+ }
+ commitStarted.await()
+
+ verify(exactly(0)) { nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) }
+ verify(exactly(0)) { nodeManager.updateNodeStatus(any(), any()) }
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalConfig(any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalModuleConfig(any()) }
+
+ releaseCommit.complete(Unit)
+ editJob.join()
+ advanceUntilIdle()
+
+ verify(exactly(1)) { nodeManager.handleReceivedUser(1234, user, any(), any(), any()) }
+ verify(exactly(1)) { nodeManager.updateNodeStatus(1234, "Ready") }
+ verify(exactly(1)) { nodeManager.handleReceivedPosition(1234, 1234, any(), any(), any()) }
+ verifySuspend(exactly(1)) { radioConfigRepository.setLocalConfig(config) }
+ verifySuspend(exactly(1)) { radioConfigRepository.setLocalModuleConfig(moduleConfig) }
+ }
+
+ @Test
+ fun editSettingsRetainsInterleavedStagedLocalProjections() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val user = User(id = "!000004d2", long_name = "Concurrent owner")
+ val config = Config(device = Config.DeviceConfig())
+ val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Ready"))
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } returns Unit
+
+ controller.editLocalSettings {
+ coroutineScope {
+ launch { setOwner(user) }
+ launch { setConfig(config) }
+ launch { setModuleConfig(moduleConfig) }
+ }
+ }
+ advanceUntilIdle()
+
+ verify(exactly(1)) { nodeManager.handleReceivedUser(1234, user, any(), any(), any()) }
+ verify(exactly(1)) { nodeManager.updateNodeStatus(1234, "Ready") }
+ verifySuspend(exactly(1)) { radioConfigRepository.setLocalConfig(config) }
+ verifySuspend(exactly(1)) { radioConfigRepository.setLocalModuleConfig(moduleConfig) }
+ }
+
+ @Test
+ fun editSettingsDoesNotProjectZeroPositionWhenRemovingFixedPosition() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val writes = mutableListOf<AdminMessage>()
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ writes += (it.args[3] as () -> AdminMessage)()
+ }
+
+ controller.editLocalSettings {
+ setFixedPosition(Position(latitude = 0.0, longitude = 0.0, altitude = 0, time = 1, satellitesInView = 9))
+ }
+ advanceUntilIdle()
+
+ assertEquals(true, writes.single().remove_fixed_position)
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsRejectsFixedPositionWriteBeforeDeviceMutationWhenLocalNodeIsUnavailable() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = null)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+
+ assertFailsWith<LocalNodeUnavailableException> {
+ controller.editSettings(destNum = 99) {
+ setFixedPosition(Position(latitude = 47.6, longitude = -122.3, altitude = 42))
+ }
+ }
+
+ verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsContinuesLocalProjectionReconciliationAfterProjectionFailure() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val user = User(id = "!000004d2", long_name = "Committed owner")
+ val config = Config(device = Config.DeviceConfig())
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+ every { nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) } throws
+ IllegalStateException("projection failed")
+
+ controller.editLocalSettings {
+ setOwner(user)
+ setConfig(config)
+ }
+ advanceUntilIdle()
+
+ verifySuspend(exactly(1)) { radioConfigRepository.setLocalConfig(config) }
+ }
+
+ @Test
+ fun editSettingsDiscardsStagedLocalProjectionsWhenCommitFails() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns
+ dispatchedSendResult(AwaitedSendStatus.RADIO_REJECTED)
+
+ assertFailsWith<EditSettingsTransactionException> {
+ controller.editLocalSettings {
+ setOwner(User(id = "!000004d2", long_name = "Uncommitted owner"))
+ setConfig(Config(device = Config.DeviceConfig()))
+ setModuleConfig(ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Stale")))
+ setFixedPosition(Position(latitude = 1.0, longitude = 2.0, altitude = 3))
+ }
+ }
+ advanceUntilIdle()
+
+ verify(exactly(0)) { nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) }
+ verify(exactly(0)) { nodeManager.updateNodeStatus(any(), any()) }
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalConfig(any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalModuleConfig(any()) }
+ }
+
+ @Test
+ fun editSettingsDiscardsStagedLocalProjectionsWhenBlockFails() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val blockFailure = IllegalArgumentException("transaction failed after writes")
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+
+ val failure =
+ assertFailsWith<IllegalArgumentException> {
+ controller.editLocalSettings {
+ setOwner(User(id = "!000004d2", long_name = "Rolled back owner"))
+ setConfig(Config(device = Config.DeviceConfig()))
+ setModuleConfig(
+ ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Rolled back")),
+ )
+ setFixedPosition(Position(latitude = 4.0, longitude = 5.0, altitude = 6))
+ throw blockFailure
+ }
+ }
+ advanceUntilIdle()
+
+ assertSame(blockFailure, failure)
+ verify(exactly(0)) { nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) }
+ verify(exactly(0)) { nodeManager.updateNodeStatus(any(), any()) }
+ verify(exactly(0)) { nodeManager.handleReceivedPosition(any(), any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalConfig(any()) }
+ verifySuspend(exactly(0)) { radioConfigRepository.setLocalModuleConfig(any()) }
+ }
+
+ @Test
+ fun editSettingsRejectsFailedBeginBeforeRunningWrites() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
+ var blockRan = false
+
+ val failure =
+ assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings { blockRan = true } }
+
+ assertEquals(editSettingsBoundaryFailureMessage("begin"), failure.message)
+ assertFalse(blockRan)
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { commandSender.sendAdmin(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsReportsFailedCommitAfterOrderedWrites() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns
+ dispatchedSendResult(AwaitedSendStatus.RADIO_REJECTED)
+
+ val failure =
+ assertFailsWith<EditSettingsTransactionException> {
+ controller.editLocalSettings {
+ setChannel(
+ Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "Primary")),
+ )
+ }
+ }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.RADIO_REJECTED, dispatched = true),
+ failure.message,
+ )
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdmin(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsCommitsAfterWriteFailureAndRethrowsOriginal() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val writeFailure = IllegalArgumentException("settings write failed")
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+ everySuspend { commandSender.sendAdmin(any(), any(), any(), any()) } throws writeFailure
+
+ val failure =
+ assertFailsWith<IllegalArgumentException> {
+ controller.editLocalSettings {
+ setChannel(Channel(index = 0, role = Channel.Role.PRIMARY, settings = ChannelSettings(name = "A")))
+ }
+ }
+
+ assertSame(writeFailure, failure)
+ assertTrue(failure.suppressedExceptions.isEmpty())
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdmin(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsSuppressesCommitFailureOnBlockFailure() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns
+ dispatchedSendResult(AwaitedSendStatus.RADIO_REJECTED)
+ val blockFailure = IllegalArgumentException("settings write failed")
+
+ val failure = assertFailsWith<IllegalArgumentException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertEquals(
+ listOf(editSettingsCommitFailureMessage(AwaitedSendStatus.RADIO_REJECTED, dispatched = true)),
+ failure.suppressedExceptions.map(Throwable::message),
+ )
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsSuppressesDistinctCommitFailureWithSameSignature() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } throws
+ IllegalStateException("same failure")
+ val blockFailure = IllegalStateException("same failure")
+
+ val failure = assertFailsWith<IllegalStateException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertEquals(listOf("same failure"), failure.suppressedExceptions.map(Throwable::message))
+ }
+
+ @Test
+ fun editSettingsDoesNotSuppressCommitFailureCausedByBlockFailure() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ val blockFailure = IllegalStateException("settings write failed")
+ val recoveredBlockFailure =
+ IllegalStateException(
+ "outer coroutine recovery wrapper",
+ IllegalStateException("recovered settings write failure", blockFailure),
+ )
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } throws recoveredBlockFailure
+
+ val failure = assertFailsWith<IllegalStateException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertTrue(failure.suppressedExceptions.isEmpty())
+ }
+
+ @Test
+ fun editSettingsAcceptsDispatchedCommitWhenLocalTransportDeparts() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsWaitsForCanonicalDepartureAfterTransportStops() = runTest {
+ var stateAtCommitReturn: ConnectionState? = null
+ val (controller, serviceRepository) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ backgroundScope.launch { serviceRepository.setConnectionState(ConnectionState.Disconnected) }
+ stateAtCommitReturn = serviceRepository.connectionState.value
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ assertEquals(ConnectionState.Connected, stateAtCommitReturn)
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ assertEquals(ConnectionState.Disconnected, serviceRepository.connectionState.value)
+ }
+
+ @Test
+ fun editSettingsAcceptsCapturedDisconnectAfterFastReconnect() = runTest {
+ val (controller, serviceRepository) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ serviceRepository.setConnectionState(ConnectionState.Connecting)
+ serviceRepository.setConnectionState(ConnectionState.Connected)
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ assertEquals(ConnectionState.Connected, serviceRepository.connectionState.value)
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsRejectsTransportStopBeforeCommitTransmission() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ AwaitedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = false),
+ failure.message,
+ )
+ }
+
+ @Test
+ fun editSettingsRejectsTransportStopWithoutObservedDeparture() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED) }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = true),
+ failure.message,
+ )
+ }
+
+ @Test
+ fun editSettingsDoesNotAcceptDepartureFromQueuedPredecessorAsCommitEvidence() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(
+ scope = backgroundScope,
+ beforeCommitDispatch = { repository ->
+ repository.setConnectionState(ConnectionState.Disconnected)
+ repository.setConnectionState(ConnectionState.Connected)
+ },
+ ) {
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = true),
+ failure.message,
+ )
+ }
+
+ @Test
+ fun editSettingsAcceptsTimedOutCommitWhenLocalTransportDeparts() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ dispatchedSendResult(AwaitedSendStatus.TIMED_OUT)
+ }
+
+ controller.editLocalSettings {}
+
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsRejectsTimedOutCommitWithoutObservedDeparture() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { dispatchedSendResult(AwaitedSendStatus.TIMED_OUT) }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings {} }
+
+ assertEquals(editSettingsCommitFailureMessage(AwaitedSendStatus.TIMED_OUT, dispatched = true), failure.message)
+ }
+
+ @Test
+ fun editSettingsAcceptsDeviceSleepAsPostDispatchDeparture() = runTest {
+ var departuresAtCommit = 0L
+ var departuresAfterSleep = 0L
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ departuresAtCommit = serviceRepository.connectionLifecycle.value.epochs.departures
+ serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
+ departuresAfterSleep = serviceRepository.connectionLifecycle.value.epochs.departures
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ assertTrue(
+ departuresAfterSleep > departuresAtCommit,
+ "DeviceSleep must publish a departure before the commit result is evaluated",
+ )
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsAcceptsConnectingAsPostDispatchDeparture() = runTest {
+ val (controller, serviceRepository) =
+ createCommitBoundaryFixture(backgroundScope) { repository ->
+ repository.setConnectionState(ConnectionState.Connecting)
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ assertEquals(ConnectionState.Connecting, serviceRepository.connectionState.value)
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsAcceptsDepartureThatArrivesWithinCommitDepartureTimeout() = runTest {
+ val (controller, serviceRepository) =
+ createCommitBoundaryFixture(backgroundScope) { repository ->
+ backgroundScope.launch {
+ delay(COMMIT_DEPARTURE_TIMEOUT.inWholeMilliseconds / 2)
+ repository.setConnectionState(ConnectionState.Disconnected)
+ }
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ controller.editLocalSettings {}
+
+ assertEquals(ConnectionState.Disconnected, serviceRepository.connectionState.value)
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsRejectsDepartureThatArrivesAfterCommitDepartureTimeout() = runTest {
+ val (controller, serviceRepository) =
+ createCommitBoundaryFixture(backgroundScope) { repository ->
+ backgroundScope.launch {
+ delay(COMMIT_DEPARTURE_TIMEOUT.inWholeMilliseconds + 1)
+ repository.setConnectionState(ConnectionState.Disconnected)
+ }
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = true),
+ failure.message,
+ )
+ advanceTimeBy(COMMIT_DEPARTURE_TIMEOUT.inWholeMilliseconds + 1)
+ runCurrent()
+ assertEquals(ConnectionState.Disconnected, serviceRepository.connectionState.value)
+ }
+
+ @Test
+ fun editSettingsDoesNotTreatLocalDepartureAsRemoteCommitAcceptance() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editSettings(destNum = 5678) {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = true),
+ failure.message,
+ )
+ }
+
+ @Test
+ fun editSettingsDoesNotTreatDestinationZeroAsLocalWhenNodeIdentityIsUnknown() = runTest {
+ val (controller, _) =
+ createCommitBoundaryFixture(backgroundScope, myNodeNum = null) { serviceRepository ->
+ serviceRepository.setConnectionState(ConnectionState.Disconnected)
+ dispatchedSendResult(AwaitedSendStatus.TRANSPORT_STOPPED)
+ }
+
+ val failure = assertFailsWith<EditSettingsTransactionException> { controller.editSettings(destNum = 0) {} }
+
+ assertEquals(
+ editSettingsCommitFailureMessage(AwaitedSendStatus.TRANSPORT_STOPPED, dispatched = true),
+ failure.message,
+ )
+ }
+
+ @Test
+ fun editLocalSettingsFailsBeforeBeginWhenNodeIdentityIsUnknown() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = null)
+
+ assertFailsWith<LocalNodeUnavailableException> { controller.editLocalSettings {} }
+
+ verifySuspend(exactly(0)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(0)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun editSettingsCommitsWhenCallerIsCancelled() = runTest {
+ val controller = createController(scope = backgroundScope, myNodeNum = 1234)
+ everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
+ everySuspend { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) } returns acceptedSendResult()
+ val blockStarted = CompletableDeferred<Unit>()
+ val keepBlockOpen = CompletableDeferred<Unit>()
+
+ val job = launch {
+ controller.editLocalSettings {
+ setOwner(User(id = "!000004d2", long_name = "Cancelled owner"))
+ blockStarted.complete(Unit)
+ keepBlockOpen.await()
+ }
+ }
+ blockStarted.await()
+ job.cancel(CancellationException("cancel settings transaction"))
+ job.join()
+
+ assertTrue(job.isCancelled)
+ verify(exactly(0)) { nodeManager.handleReceivedUser(any(), any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwait(any(), any(), any(), any()) }
+ verifySuspend(exactly(1)) { commandSender.sendAdminAwaitResult(any(), any(), any(), any()) }
+ }
+
@Test
fun importContactSendsAdminAndUpdatesNodeManager() = runTest {
val controller = createController(scope = backgroundScope)
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt
index 0ba6b40295..e6ba0653db 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/ServiceRepositoryImplTest.kt
@@ -25,6 +25,7 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeoutOrNull
+import org.meshtastic.core.model.ConnectionEpochs
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.TracerouteResponse
import kotlin.test.Test
@@ -39,6 +40,7 @@ class ServiceRepositoryImplTest {
val repository = ServiceRepositoryImpl()
assertEquals(ConnectionState.Disconnected, repository.connectionState.value)
+ assertEquals(ConnectionEpochs(), repository.connectionEpochs.value)
assertNull(repository.clientNotification.value)
assertNull(repository.errorMessage.value)
assertNull(repository.connectionProgress.value)
@@ -65,6 +67,35 @@ class ServiceRepositoryImplTest {
assertEquals(ConnectionState.Connecting, repository.connectionState.value)
}
+ @Test
+ fun connectionEpochsPreserveRapidDepartureAndHandshakeTransitions() = runTest {
+ val repository = ServiceRepositoryImpl()
+ repository.setConnectionState(ConnectionState.Connected)
+ val baseline = repository.connectionEpochs.value
+
+ repository.setConnectionState(ConnectionState.Disconnected)
+ repository.setConnectionState(ConnectionState.Connecting)
+ repository.setConnectionState(ConnectionState.Connected)
+
+ assertEquals(ConnectionState.Connected, repository.connectionState.value)
+ assertEquals(baseline.departures + 1, repository.connectionEpochs.value.departures)
+ assertEquals(baseline.completedHandshakes + 1, repository.connectionEpochs.value.completedHandshakes)
+ assertEquals(baseline.completedHandshakes, repository.connectionEpochs.value.handshakesAtLastDeparture)
+ assertEquals(ConnectionState.Disconnected, repository.connectionEpochs.value.lastDepartureState)
+ }
+
+ @Test
+ fun duplicateConnectionStatesDoNotAdvanceEpochs() = runTest {
+ val repository = ServiceRepositoryImpl()
+ repository.setConnectionState(ConnectionState.Connected)
+ val afterHandshake = repository.connectionEpochs.value
+
+ repository.setConnectionState(ConnectionState.Connected)
+
+ assertEquals(ConnectionEpochs(completedHandshakes = 1), afterHandshake)
+ assertEquals(afterHandshake, repository.connectionEpochs.value)
+ }
+
@Test
fun setErrorMessageEmitsAndCanBeCleared() = runTest {
val repository = ServiceRepositoryImpl()
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
index 481dc081c4..b1a9ea57f7 100644
--- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/SharedRadioInterfaceServiceLivenessTest.kt
@@ -207,9 +207,8 @@ class SharedRadioInterfaceServiceLivenessTest {
var closeCompletedCount = 0
private set
- // Liveness restart skips the polite-disconnect frame (sendPoliteDisconnect = false), so no
- // outbound data is expected; satisfy the contract with a no-op.
- override fun handleSendToRadio(p: ByteArray) = Unit
+ // Liveness restart skips the polite-disconnect frame, so reject any unexpected outbound handoff.
+ override fun handleSendToRadio(p: ByteArray): Boolean = false
override suspend fun close() {
closeCalled = true
@@ -220,6 +219,34 @@ class SharedRadioInterfaceServiceLivenessTest {
}
}
+ /** Triggers a liveness restart from inside one synchronous send handoff. */
+ private class ReentrantRestartTransport(private val requestRestart: () -> Unit) : RadioTransport {
+ var handoffCompleted = false
+ private set
+
+ var closeCalled = false
+ private set
+
+ var closeObservedBeforeHandoff = false
+ private set
+
+ private var restartRequested = false
+
+ override fun handleSendToRadio(p: ByteArray): Boolean {
+ if (!restartRequested) {
+ restartRequested = true
+ requestRestart()
+ }
+ handoffCompleted = true
+ return true
+ }
+
+ override suspend fun close() {
+ closeCalled = true
+ if (!handoffCompleted) closeObservedBeforeHandoff = true
+ }
+ }
+
/** Controllable clock — tests advance this manually so all time comparisons are deterministic. */
private var clock: Long = 0L
@@ -489,6 +516,43 @@ class SharedRadioInterfaceServiceLivenessTest {
assertTrue(createdTransports.single().closeCalled, "cancellation must not strand the revoked transport")
}
+ @Test
+ fun `heartbeat cannot bypass transport admission while teardown drains a session lease`() =
+ runTest(testDispatcher) {
+ val service = createConnectedService("xAA:BB:CC:DD:EE:FF")
+ val transport = createdTransports.single()
+ service.keepAlive(now = 30_000L)
+ assertTrue(transport.keepAliveCalled, "an admitted session must receive its heartbeat")
+ transport.clearKeepAlive()
+
+ val session = requireNotNull(service.activeSession.value)
+ val workStarted = CompletableDeferred<Unit>()
+ val releaseWork = CompletableDeferred<Unit>()
+ val workJob = launch {
+ service.runWithSessionLease(session) {
+ workStarted.complete(Unit)
+ releaseWork.await()
+ }
+ }
+ workStarted.await()
+
+ val disconnectJob = launch { service.disconnect() }
+ try {
+ testDispatcher.scheduler.runCurrent()
+ assertFalse(disconnectJob.isCompleted, "disconnect must wait for the admitted lease")
+
+ service.keepAlive(now = 60_000L)
+
+ assertFalse(transport.keepAliveCalled, "teardown must reject a late heartbeat")
+ } finally {
+ releaseWork.complete(Unit)
+ workJob.join()
+ testDispatcher.scheduler.runCurrent()
+ advanceTimeBy(1_000L)
+ disconnectJob.join()
+ }
+ }
+
// ─── BLE: Liveness timeout triggers recovery ───────────────────────────────────────────────
@Test
@@ -621,7 +685,7 @@ class SharedRadioInterfaceServiceLivenessTest {
try {
val oldTransport = createdTransports.first()
- oldTransport.sentData.clear()
+ oldTransport.clearSentData()
clock = 65_000L
service.checkLiveness()
@@ -662,6 +726,90 @@ class SharedRadioInterfaceServiceLivenessTest {
}
}
+ @Test
+ fun `transport teardown drains a synchronously admitted send before close`() = runTest(testDispatcher) {
+ val transports = mutableListOf<RadioTransport>()
+ lateinit var service: SharedRadioInterfaceService
+ lateinit var initialTransport: ReentrantRestartTransport
+ // The provider runs before `service` is assigned; keep the callback disarmed until construction
+ // completes so the captured lateinit reference cannot be touched from the transport factory.
+ var restartArmed = false
+ val transportProvider: () -> RadioTransport = {
+ if (transports.isEmpty()) {
+ ReentrantRestartTransport {
+ if (restartArmed) {
+ clock = 65_000L
+ service.checkLiveness()
+ }
+ }
+ .also {
+ initialTransport = it
+ transports += it
+ }
+ } else {
+ FakeRadioTransport().also { transports += it }
+ }
+ }
+
+ clock = 0L
+ service = createConnectedService("xAA:BB:CC:DD:EE:FF", transportProvider)
+ try {
+ restartArmed = true
+ val accepted = service.trySendToRadio(byteArrayOf(1, 2, 3))
+ testDispatcher.scheduler.runCurrent()
+
+ assertTrue(accepted)
+ assertTrue(initialTransport.handoffCompleted)
+ assertTrue(initialTransport.closeCalled)
+ assertFalse(
+ initialTransport.closeObservedBeforeHandoff,
+ "teardown must wait until the admitted handoff releases its session lease",
+ )
+ assertEquals(2, transports.size, "the liveness restart should publish one replacement transport")
+ } finally {
+ service.disconnect()
+ advanceTimeBy(1_000L)
+ }
+ }
+
+ @Test
+ fun `transport rejection is reported to the caller`() = runTest(testDispatcher) {
+ val service =
+ createConnectedService(
+ address = "xAA:BB:CC:DD:EE:FF",
+ transportProvider = {
+ object : RadioTransport {
+ override fun handleSendToRadio(p: ByteArray): Boolean = false
+
+ override suspend fun close() = Unit
+ }
+ },
+ )
+
+ try {
+ assertFalse(service.trySendToRadio(byteArrayOf(1, 2, 3)))
+ } finally {
+ service.disconnect()
+ advanceTimeBy(1_000L)
+ }
+ }
+
+ @Test
+ fun `transport admission is closed after disconnect`() = runTest(testDispatcher) {
+ val service = createConnectedService("xAA:BB:CC:DD:EE:FF")
+
+ try {
+ service.disconnect()
+ testDispatcher.scheduler.runCurrent()
+ advanceTimeBy(1_000L)
+
+ assertFalse(service.trySendToRadio(byteArrayOf(1, 2, 3)))
+ } finally {
+ service.disconnect()
+ advanceTimeBy(1_000L)
+ }
+ }
+
@Test
fun `BLE in-flight liveness restart prevents overlapping restart via isRestarting`() = runTest(testDispatcher) {
// Deterministic in-flight overlap: a GatedFakeRadioTransport holds the first restart
@@ -1263,7 +1411,7 @@ class SharedRadioInterfaceServiceLivenessTest {
// Lock the sendPoliteDisconnect=false contract: clear any bytes recorded during the
// initial connect so sentData reflects only writes performed during restartTransport.
- initialTransport.sentData.clear()
+ initialTransport.clearSentData()
service.restartTransport()
// sendPoliteDisconnect = false → no 500ms drain inside the cycle. Under
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
index 20a4da4703..003ed61e8d 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
@@ -16,34 +16,23 @@
*/
package org.meshtastic.core.takserver
-import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.model.ConnectionState
-import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.service.LockdownState
-import org.meshtastic.core.model.service.LockdownTokenInfo
-import org.meshtastic.core.model.service.TracerouteResponse
-import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioSessionContext
-import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.testing.FakeCommandSender
+import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.core.testing.FakeTakPrefs
-import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
-import org.meshtastic.proto.ChannelSet
-import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
@@ -75,117 +64,6 @@ class TAKMeshIntegrationTest {
// ── Fakes ────────────────────────────────────────────────────────────────
// FakeTAKServerManager lives in its own file in this source set, shared with MeshToCotBroadcasterTest.
- private class FakeCommandSender : CommandSender {
- val sentPackets = mutableListOf<DataPacket>()
-
- override suspend fun sendData(p: DataPacket) {
- sentPackets.add(p)
- }
-
- override fun getCurrentPacketId(): Long = 0L
-
- override fun getCachedLocalConfig(): LocalConfig = LocalConfig()
-
- override fun getCachedChannelSet(): ChannelSet = ChannelSet()
-
- override fun generatePacketId(): Int = 1
-
- override suspend fun sendAdmin(
- destNum: Int,
- requestId: Int,
- wantResponse: Boolean,
- initFn: () -> AdminMessage,
- ) {}
-
- override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {}
-
- override suspend fun sendAdminAwait(
- destNum: Int,
- requestId: Int,
- wantResponse: Boolean,
- initFn: () -> AdminMessage,
- ): Boolean = true
-
- override suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {}
-
- override suspend fun requestPosition(destNum: Int, currentPosition: Position) {}
-
- override suspend fun setFixedPosition(destNum: Int, pos: Position) {}
-
- override suspend fun requestUserInfo(destNum: Int) {}
-
- override suspend fun requestTraceroute(requestId: Int, destNum: Int) {}
-
- override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
-
- override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
-
- override fun sendLockdownPassphrase(
- passphrase: String,
- boots: Int,
- hours: Int,
- maxSessionSeconds: Int,
- disable: Boolean,
- ) {}
-
- override fun sendLockNow() {}
- }
-
- private class FakeServiceRepository : ServiceRepository {
- private val _meshPacketFlow = MutableSharedFlow<MeshPacket>(replay = 1, extraBufferCapacity = 64)
- override val meshPacketFlow: Flow<MeshPacket> = _meshPacketFlow
-
- override val connectionState: StateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
-
- override fun setConnectionState(connectionState: ConnectionState) {}
-
- override val clientNotification: StateFlow<ClientNotification?> = MutableStateFlow(null)
-
- override fun setClientNotification(notification: ClientNotification?) {}
-
- override fun clearClientNotification() {}
-
- override val errorMessage: StateFlow<String?> = MutableStateFlow(null)
-
- override fun setErrorMessage(text: String, severity: Severity) {}
-
- override fun clearErrorMessage() {}
-
- override val connectionProgress: StateFlow<String?> = MutableStateFlow(null)
-
- override fun setConnectionProgress(text: String) {}
-
- override suspend fun emitMeshPacket(packet: MeshPacket) {
- _meshPacketFlow.emit(packet)
- }
-
- override val tracerouteResponse: StateFlow<TracerouteResponse?> = MutableStateFlow(null)
-
- override fun setTracerouteResponse(value: TracerouteResponse?) {}
-
- override fun clearTracerouteResponse() {}
-
- override val neighborInfoResponse: StateFlow<String?> = MutableStateFlow(null)
-
- override fun setNeighborInfoResponse(value: String?) {}
-
- override fun clearNeighborInfoResponse() {}
-
- override val lockdownState: StateFlow<LockdownState> = MutableStateFlow(LockdownState.None)
-
- override fun setLockdownState(state: LockdownState) {}
-
- override fun clearLockdownState() {}
-
- override val lockdownTokenInfo: StateFlow<LockdownTokenInfo?> = MutableStateFlow(null)
-
- override fun setLockdownTokenInfo(info: LockdownTokenInfo?) {}
-
- override val sessionAuthorized: StateFlow<Boolean> = MutableStateFlow(false)
-
- override fun setSessionAuthorized(authorized: Boolean) {}
- }
-
private class FakeMeshConfigHandler : MeshConfigHandler {
override val localConfig: StateFlow<LocalConfig> = MutableStateFlow(LocalConfig())
override val moduleConfig: StateFlow<LocalModuleConfig> = MutableStateFlow(LocalModuleConfig())
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
index adb0c05572..de90468c36 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeBle.kt
@@ -138,6 +138,15 @@ class FakeBleConnection :
val service = FakeBleService()
+ /**
+ * Selects the service exposed to each [profile] call. The argument is the 1-based [profile] call ordinal (the first
+ * call receives `1`). Defaults to the shared [service].
+ *
+ * A [profile] call that fails because [missingServices] is true still consumes an ordinal. The ordinal counts
+ * profile invocations, not provider invocations.
+ */
+ var profileServiceProvider: (profileCall: Int) -> BleService = { service }
+
override suspend fun connect(device: BleDevice) {
_device.value = device
_connectionState.value = BleConnectionState.Connecting
@@ -184,7 +193,7 @@ class FakeBleConnection :
// Use Dispatchers.Unconfined so notification emissions are delivered synchronously to
// collectors (write → immediate notification). This matches the original FakeBleConnection
// contract and the auto-responding pattern used by DFU/OTA transport tests.
- return CoroutineScope(Dispatchers.Unconfined).setup(service)
+ return CoroutineScope(Dispatchers.Unconfined).setup(profileServiceProvider(profileCalls))
}
override fun maximumWriteValueLength(writeType: BleWriteType): Int? = maxWriteValueLength
@@ -219,6 +228,12 @@ class FakeBleService : BleService {
/** When non-null, [write] throws this exception on every call until explicitly cleared. */
var writeException: Exception? = null
+ /**
+ * Optional suspend hook invoked after the attempt is counted and before [writeException] is evaluated, so it also
+ * runs for writes that then fail.
+ */
+ var beforeWrite: (suspend (BleCharacteristic, ByteArray) -> Unit)? = null
+
/**
* When non-null, [read] throws this exception instead of returning data. Reset to null before throwing (in the same
* call).
@@ -299,6 +314,7 @@ class FakeBleService : BleService {
override suspend fun write(characteristic: BleCharacteristic, data: ByteArray, writeType: BleWriteType) {
writeAttempts++
+ beforeWrite?.invoke(characteristic, data)
writeException?.let { ex -> throw ex }
availableCharacteristics += characteristic.uuid
writes += FakeBleWrite(characteristic = characteristic, data = data.copyOf(), writeType = writeType)
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt
new file mode 100644
index 0000000000..f82ca6c6ce
--- /dev/null
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeCommandSender.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.testing
+
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.repository.AwaitedSendResult
+import org.meshtastic.core.repository.AwaitedSendStatus
+import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import org.meshtastic.proto.AdminMessage
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.LocalConfig
+
+/** Shared recording [CommandSender] fake for tests that do not need transport behavior. */
+@Suppress("TooManyFunctions")
+class FakeCommandSender :
+ BaseFake(),
+ CommandSender {
+
+ data class AdminRequest(
+ val destNum: Int,
+ val requestId: Int,
+ val wantResponse: Boolean,
+ val message: AdminMessage,
+ val expectedConnectionVersion: Long? = null,
+ )
+
+ data class TelemetryRequest(
+ val requestId: Int,
+ val destNum: Int,
+ val typeValue: Int,
+ val expectedConnectionVersion: Long? = null,
+ )
+
+ private val mutableSentPackets = mutableListOf<DataPacket>()
+ val sentPackets: List<DataPacket>
+ get() = mutableSentPackets.map { it.snapshot() }
+
+ private val mutableAdminRequests = mutableListOf<AdminRequest>()
+ val adminRequests: List<AdminRequest>
+ get() = mutableAdminRequests.toList()
+
+ private val mutableTelemetryRequests = mutableListOf<TelemetryRequest>()
+ val telemetryRequests: List<TelemetryRequest>
+ get() = mutableTelemetryRequests.toList()
+
+ private var nextPacketId = 1
+
+ var lastPassphrase: String? = null
+ private set
+
+ var lastBoots: Int = 0
+ private set
+
+ var lastHours: Int = 0
+ private set
+
+ var lastMaxSessionSeconds: Int = 0
+ private set
+
+ var lastDisable: Boolean = false
+ private set
+
+ var lockNowCalled: Boolean = false
+ private set
+
+ var awaitedAdminResult: AwaitedSendResult =
+ AwaitedSendResult(AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 0)
+ var sendDataFailure: PacketQueueRejectedException? = null
+ var commandFailure: Exception? = null
+
+ init {
+ registerResetAction {
+ mutableSentPackets.clear()
+ mutableAdminRequests.clear()
+ mutableTelemetryRequests.clear()
+ nextPacketId = 1
+ lastPassphrase = null
+ lastBoots = 0
+ lastHours = 0
+ lastMaxSessionSeconds = 0
+ lastDisable = false
+ lockNowCalled = false
+ awaitedAdminResult = AwaitedSendResult(AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 0)
+ sendDataFailure = null
+ commandFailure = null
+ }
+ }
+
+ override fun getCurrentPacketId(): Long = nextPacketId.toLong()
+
+ override fun getCachedLocalConfig(): LocalConfig = LocalConfig()
+
+ override fun getCachedChannelSet(): ChannelSet = ChannelSet()
+
+ override fun generatePacketId(): Int = nextPacketId++
+
+ override suspend fun sendData(p: DataPacket) {
+ failCommandIfConfigured()
+ require(p.dataType != 0) { "Port numbers must be non-zero!" }
+ if (p.id == 0) p.id = generatePacketId()
+ sendDataFailure?.let { failure ->
+ p.status = MessageStatus.ERROR
+ throw failure
+ }
+ p.status = MessageStatus.QUEUED
+ p.time = nowMillis
+ mutableSentPackets += p.snapshot()
+ }
+
+ private fun DataPacket.snapshot(): DataPacket = copy().also { it.errorMessage = errorMessage }
+
+ override suspend fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
+ failCommandIfConfigured()
+ mutableAdminRequests += AdminRequest(destNum, requestId, wantResponse, initFn())
+ }
+
+ override suspend fun sendAdminForConnection(
+ destNum: Int,
+ expectedConnectionVersion: Long,
+ requestId: Int,
+ wantResponse: Boolean,
+ initFn: () -> AdminMessage,
+ ) {
+ failCommandIfConfigured()
+ mutableAdminRequests += AdminRequest(destNum, requestId, wantResponse, initFn(), expectedConnectionVersion)
+ }
+
+ override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {
+ failCommandIfConfigured()
+ mutableAdminRequests += AdminRequest(destNum, requestId = 0, wantResponse = false, message = initFn())
+ }
+
+ override suspend fun sendAdminAwaitResult(
+ destNum: Int,
+ requestId: Int,
+ wantResponse: Boolean,
+ initFn: () -> AdminMessage,
+ ): AwaitedSendResult {
+ failCommandIfConfigured()
+ mutableAdminRequests += AdminRequest(destNum, requestId, wantResponse, initFn())
+ return awaitedAdminResult
+ }
+
+ override suspend fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
+ failCommandIfConfigured()
+ }
+
+ override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
+ failCommandIfConfigured()
+ }
+
+ override suspend fun setFixedPosition(destNum: Int, pos: Position) {
+ failCommandIfConfigured()
+ }
+
+ override suspend fun requestUserInfo(destNum: Int) {
+ failCommandIfConfigured()
+ }
+
+ override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
+ failCommandIfConfigured()
+ }
+
+ override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
+ failCommandIfConfigured()
+ mutableTelemetryRequests += TelemetryRequest(requestId, destNum, typeValue)
+ }
+
+ override suspend fun requestTelemetryForConnection(
+ requestId: Int,
+ destNum: Int,
+ typeValue: Int,
+ expectedConnectionVersion: Long,
+ ) {
+ failCommandIfConfigured()
+ mutableTelemetryRequests += TelemetryRequest(requestId, destNum, typeValue, expectedConnectionVersion)
+ }
+
+ override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
+ failCommandIfConfigured()
+ }
+
+ private fun failCommandIfConfigured() {
+ commandFailure?.let { throw it }
+ }
+
+ override fun sendLockdownPassphrase(
+ passphrase: String,
+ boots: Int,
+ hours: Int,
+ maxSessionSeconds: Int,
+ disable: Boolean,
+ ) {
+ failCommandIfConfigured()
+ lastPassphrase = passphrase
+ lastBoots = boots
+ lastHours = hours
+ lastMaxSessionSeconds = maxSessionSeconds
+ lastDisable = disable
+ }
+
+ override fun sendLockNow() {
+ failCommandIfConfigured()
+ lockNowCalled = true
+ }
+}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
index 8faa529af9..40f4dd8207 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt
@@ -16,11 +16,15 @@
*/
package org.meshtastic.core.testing
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.core.repository.AdminEditScope
+import org.meshtastic.core.repository.ConnectionStateHolder
+import org.meshtastic.core.repository.EditSettingsTransactionException
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.RadioController
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
@@ -44,8 +48,10 @@ class FakeRadioController :
}
/** Canonical app-level connection state, mirroring [ServiceRepository][connectionState] semantics. */
- private val _connectionState = mutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
- override val connectionState: StateFlow<ConnectionState> = _connectionState
+ private val connectionStateHolder = ConnectionStateHolder()
+ override val connectionLifecycle = connectionStateHolder.connectionLifecycle
+ override val connectionState = connectionStateHolder.connectionState
+ override val connectionEpochs = connectionStateHolder.connectionEpochs
private val _clientNotification = mutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
@@ -54,17 +60,80 @@ class FakeRadioController :
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
- /** Every [setLocalConfig] call, in order — lets tests assert e.g. that a scan restored the home LoRa preset. */
- val localConfigs = mutableListOf<Config>()
+ /** One local or admin configuration write. Local writes have no destination. */
+ data class ConfigWrite(val destination: Int?, val config: Config)
+
+ /** One module configuration write. Local writes have no destination. */
+ data class ModuleConfigWrite(val destination: Int?, val config: ModuleConfig)
+
+ /** One local or admin channel write. Local writes have no destination. */
+ data class ChannelWrite(val destination: Int?, val channel: Channel)
+
+ /** One owner write. Local writes have no destination. */
+ data class OwnerWrite(val destination: Int?, val user: User)
+
+ /** Every local or admin configuration write, preserving destination and call order together. */
+ val configWrites = mutableListOf<ConfigWrite>()
+
+ /** Local configuration payloads in call order. Prefer [configWrites] when destination identity matters. */
+ val localConfigs: List<Config>
+ get() = configWrites.filter { it.destination == null }.map(ConfigWrite::config)
+
val lastLocalConfig: Config?
- get() = localConfigs.lastOrNull()
+ get() = configWrites.lastOrNull { it.destination == null }?.config
- /** Every [setLocalChannel] call, in order. */
- val localChannels = mutableListOf<Channel>()
+ /** Every local or admin channel write, preserving destination and call order together. */
+ val channelWrites = mutableListOf<ChannelWrite>()
+
+ /** Local channel payloads in call order. Prefer [channelWrites] when destination identity matters. */
+ val localChannels: List<Channel>
+ get() = channelWrites.filter { it.destination == null }.map(ChannelWrite::channel)
/** Every config and channel write, in their shared call order. */
val settingsOperations = mutableListOf<SettingsOperation>()
+ /** Every [setFixedPosition] call, in order. */
+ val fixedPositions = mutableListOf<Position>()
+
+ /** Every module configuration write, preserving destination and call order together. */
+ val moduleConfigWrites = mutableListOf<ModuleConfigWrite>()
+
+ /** Module configuration payloads in call order. Prefer [moduleConfigWrites] when destination identity matters. */
+ val allModuleConfigs: List<ModuleConfig>
+ get() = moduleConfigWrites.map(ModuleConfigWrite::config)
+
+ /** Local module configuration payloads in call order. Prefer [moduleConfigWrites] when destination matters. */
+ val localModuleConfigs: List<ModuleConfig>
+ get() = moduleConfigWrites.filter { it.destination == null }.map(ModuleConfigWrite::config)
+
+ /** Destination node for every module configuration write, in order. Local writes have no destination. */
+ val moduleConfigDestinations: List<Int?>
+ get() = moduleConfigWrites.map(ModuleConfigWrite::destination)
+
+ /** Every local or admin owner write, preserving destination and call order together. */
+ val ownerWrites = mutableListOf<OwnerWrite>()
+
+ /** Destination node for every edit transaction, in order. Local edits use the fake's sentinel value of zero. */
+ val editSettingsDestinations = mutableListOf<Int>()
+
+ /** High-level admin operation order, including edit transaction boundaries. */
+ val adminOperations = mutableListOf<String>()
+
+ /** When true, an edit transaction fails before the begin boundary is recorded. */
+ var failEditSettingsBegin: Boolean = false
+
+ /** Test hook invoked after an edit transaction commits. */
+ var onEditSettingsCommitted: suspend () -> Unit = {}
+
+ /** Test hook invoked before a fixed-position write is recorded. */
+ var onSetFixedPosition: suspend (Int, Position) -> Unit = { _, _ -> }
+
+ /** Test hook invoked after a standalone module-config write. */
+ var onStandaloneModuleConfig: suspend (ModuleConfig) -> Unit = {}
+
+ /** Test hook invoked after a standalone general-config write. */
+ var onStandaloneConfig: suspend (Config) -> Unit = {}
+
var throwOnSend: Boolean = false
/** Deterministic suspension/fault hook invoked before a packet is recorded as sent. */
@@ -73,9 +142,18 @@ class FakeRadioController :
/** When true, [setLocalConfig] throws — simulates the radio link dropping mid config write. */
var throwOnSetLocalConfig: Boolean = false
+ /** Number of upcoming local-config writes to reject through the production queue-admission failure contract. */
+ var rejectLocalConfigWritesRemaining: Int = 0
+
+ /** Number of upcoming local-channel writes to reject through the production queue-admission failure contract. */
+ var rejectLocalChannelWritesRemaining: Int = 0
+
+ /** Failure thrown by [requestNeighborInfo], when set. */
+ var requestNeighborInfoFailure: Exception? = null
+ val neighborInfoRequests = mutableListOf<Pair<Int, Int>>()
+
/**
- * When set, a channel write throws once [localChannels] has reached this many entries — simulates a mid-write
- * failure.
+ * When set, a channel write throws after this many total local/admin [channelWrites] — simulates mid-write failure.
*/
var failChannelWriteAfter: Int? = null
var lastSetDeviceAddress: String? = null
@@ -93,15 +171,30 @@ class FakeRadioController :
init {
registerResetAction {
+ connectionStateHolder.reset()
sentPackets.clear()
favoritedNodes.clear()
sentSharedContacts.clear()
- localConfigs.clear()
- localChannels.clear()
+ configWrites.clear()
+ channelWrites.clear()
settingsOperations.clear()
+ fixedPositions.clear()
+ moduleConfigWrites.clear()
+ ownerWrites.clear()
+ editSettingsDestinations.clear()
+ adminOperations.clear()
+ failEditSettingsBegin = false
+ onEditSettingsCommitted = {}
+ onSetFixedPosition = { _, _ -> }
+ onStandaloneModuleConfig = {}
+ onStandaloneConfig = {}
throwOnSend = false
onSendMessage = {}
throwOnSetLocalConfig = false
+ rejectLocalConfigWritesRemaining = 0
+ rejectLocalChannelWritesRemaining = 0
+ requestNeighborInfoFailure = null
+ neighborInfoRequests.clear()
failChannelWriteAfter = null
lastSetDeviceAddress = null
lastSetOwnerUser = null
@@ -144,35 +237,65 @@ class FakeRadioController :
override suspend fun setLocalConfig(config: Config) {
if (throwOnSetLocalConfig) error("Fake local config write failure")
- localConfigs.add(config)
+ if (rejectLocalConfigWritesRemaining > 0) {
+ rejectLocalConfigWritesRemaining--
+ throw PacketQueueRejectedException("Local config")
+ }
+ configWrites.add(ConfigWrite(destination = null, config = config))
settingsOperations.add(SettingsOperation.SetConfig(config))
}
override suspend fun setLocalChannel(channel: Channel) {
- localChannels.add(channel)
+ if (rejectLocalChannelWritesRemaining > 0) {
+ rejectLocalChannelWritesRemaining--
+ throw PacketQueueRejectedException("Local channel")
+ }
+ failChannelWriteAfter?.let { if (channelWrites.size >= it) error("Fake channel write failure") }
+ channelWrites.add(ChannelWrite(destination = null, channel = channel))
settingsOperations.add(SettingsOperation.SetChannel(channel))
}
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
lastSetOwnerUser = user
+ ownerWrites.add(OwnerWrite(destination = destNum.takeUnless { it == 0 }, user = user))
+ adminOperations.add("owner")
}
override suspend fun setHamMode(destNum: Int, hamParameters: HamParameters, packetId: Int) {}
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
- localConfigs.add(config)
+ recordConfigWrite(destNum, config, invokeStandaloneHook = true)
+ }
+
+ override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
+ recordModuleConfigWrite(destNum, config, invokeStandaloneHook = true)
+ }
+
+ private suspend fun recordConfigWrite(destNum: Int, config: Config, invokeStandaloneHook: Boolean) {
+ configWrites.add(ConfigWrite(destination = destNum.takeUnless { it == 0 }, config = config))
settingsOperations.add(SettingsOperation.SetConfig(config))
+ adminOperations.add("config:update")
+ if (invokeStandaloneHook) onStandaloneConfig(config)
}
- override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {}
+ private suspend fun recordModuleConfigWrite(destNum: Int, config: ModuleConfig, invokeStandaloneHook: Boolean) {
+ moduleConfigWrites.add(ModuleConfigWrite(destination = destNum.takeUnless { it == 0 }, config = config))
+ adminOperations.add("module:update")
+ if (invokeStandaloneHook) onStandaloneModuleConfig(config)
+ }
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
- failChannelWriteAfter?.let { if (localChannels.size >= it) error("Fake channel write failure") }
- localChannels.add(channel)
+ failChannelWriteAfter?.let { if (channelWrites.size >= it) error("Fake channel write failure") }
+ channelWrites.add(ChannelWrite(destination = destNum.takeUnless { it == 0 }, channel = channel))
settingsOperations.add(SettingsOperation.SetChannel(channel))
+ adminOperations.add("channel:${channel.index}")
}
- override suspend fun setFixedPosition(destNum: Int, position: Position) {}
+ override suspend fun setFixedPosition(destNum: Int, position: Position) {
+ onSetFixedPosition(destNum, position)
+ fixedPositions.add(position)
+ adminOperations.add("fixed-position")
+ }
override suspend fun setRingtone(destNum: Int, ringtone: String) {}
@@ -218,18 +341,39 @@ class FakeRadioController :
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
- override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
+ override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
+ neighborInfoRequests += requestId to destNum
+ requestNeighborInfoFailure?.let { throw it }
+ }
+
+ private fun Throwable.containsCauseIdentity(expected: Throwable): Boolean {
+ val visited = mutableListOf<Throwable>()
+ var current: Throwable? = this
+ while (current != null && visited.none { it === current }) {
+ if (current === expected) break
+ visited += current
+ current = current.cause
+ }
+ return current === expected
+ }
override suspend fun editSettings(destNum: Int, block: suspend AdminEditScope.() -> Unit) {
+ editSettingsDestinations.add(destNum)
editSettingsCalled = true
+ if (failEditSettingsBegin) {
+ throw EditSettingsTransactionException("Device rejected or timed out while sending edit-settings begin")
+ }
+ adminOperations.add("begin")
val scope =
object : AdminEditScope {
- override suspend fun setOwner(user: User) = setOwner(destNum, user, generatePacketId())
+ override suspend fun setOwner(user: User) =
+ this@FakeRadioController.setOwner(destNum, user, generatePacketId())
- override suspend fun setConfig(config: Config) = setConfig(destNum, config, generatePacketId())
+ override suspend fun setConfig(config: Config) =
+ recordConfigWrite(destNum, config, invokeStandaloneHook = false)
override suspend fun setModuleConfig(config: ModuleConfig) =
- setModuleConfig(destNum, config, generatePacketId())
+ recordModuleConfigWrite(destNum, config, invokeStandaloneHook = false)
override suspend fun setChannel(channel: Channel) =
setRemoteChannel(destNum, channel, generatePacketId())
@@ -237,7 +381,24 @@ class FakeRadioController :
override suspend fun setFixedPosition(position: Position) =
this@FakeRadioController.setFixedPosition(destNum, position)
}
- scope.block()
+ // Production has no firmware abort boundary: once begin is accepted, commit must still close the edit session
+ // after a block failure. Preserve that failure while attempting the same non-cancellable commit boundary here.
+ val blockResult = runCatching { scope.block() }
+ val commitResult = runCatching {
+ kotlinx.coroutines.withContext(NonCancellable) {
+ adminOperations.add("commit")
+ onEditSettingsCommitted()
+ }
+ }
+
+ blockResult.exceptionOrNull()?.let { blockFailure ->
+ commitResult
+ .exceptionOrNull()
+ ?.takeUnless { it.containsCauseIdentity(blockFailure) }
+ ?.let(blockFailure::addSuppressed)
+ throw blockFailure
+ }
+ commitResult.getOrThrow()
}
override suspend fun editLocalSettings(block: suspend AdminEditScope.() -> Unit) = editSettings(0, block)
@@ -262,9 +423,7 @@ class FakeRadioController :
// --- Helper methods for testing ---
- fun setConnectionState(state: ConnectionState) {
- _connectionState.value = state
- }
+ fun setConnectionState(state: ConnectionState) = connectionStateHolder.setConnectionState(state)
fun setClientNotification(notification: ClientNotification?) {
_clientNotification.value = notification
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
index a735740bae..26dd7fbffd 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceService.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.core.testing
+import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
@@ -77,6 +78,11 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
private var sessionDrainWaiter: CompletableDeferred<Unit>? = null
private val sessionOperationMutex = Mutex()
+ /** Number of lease-release invariant violations observed by this fake. Finally paths record rather than throw. */
+ private var sessionLeaseInvariantViolationsState: Int = 0
+ val sessionLeaseInvariantViolations: Int
+ get() = synchronized(sessionAdmissionLock) { sessionLeaseInvariantViolationsState }
+
override fun isSessionActive(session: RadioSessionContext): Boolean =
synchronized(sessionAdmissionLock) { sessionAdmissionOpen && _activeSession.value == session }
@@ -91,16 +97,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
session: RadioSessionContext,
block: suspend (RadioSessionLease) -> Unit,
): Boolean {
- val admittedSession =
- synchronized(sessionAdmissionLock) {
- val active = _activeSession.value
- if (!sessionAdmissionOpen || active != session) {
- null
- } else {
- admittedSessionOperations++
- active
- }
- } ?: return false
+ val admittedSession = admitSessionOperation(session) ?: return false
val lease =
object : RadioSessionLease {
@@ -114,20 +111,7 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
block(lease)
return true
} finally {
- val waiter =
- synchronized(sessionAdmissionLock) {
- check(_activeSession.value == admittedSession) {
- "Fake session changed before an admitted operation released its lease"
- }
- check(admittedSessionOperations > 0) { "Session operation count underflow" }
- admittedSessionOperations--
- if (admittedSessionOperations == 0) {
- sessionDrainWaiter.also { sessionDrainWaiter = null }
- } else {
- null
- }
- }
- waiter?.complete(Unit)
+ releaseSessionOperation(admittedSession)
}
}
@@ -145,7 +129,15 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
private val _connectionError = MutableSharedFlow<String>()
override val connectionError: Flow<String> = _connectionError.asFlow()
- val sentToRadio = mutableListOf<ByteArray>()
+ private val _sentToRadio = mutableListOf<ByteArray>()
+
+ /** Thread-safe lifetime history of writes accepted across all admitted fake transport sessions. */
+ val sentToRadio: List<ByteArray>
+ get() = synchronized(sessionAdmissionLock) { _sentToRadio.map { it.copyOf() } }
+
+ /** Set to true to simulate an admitted transport rejecting the byte handoff. */
+ var rejectAdmittedSends: Boolean = false
+
var connectCalled = false
var restartTransportCalled: Boolean = false
private set
@@ -155,8 +147,19 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
/** No capture asset in tests; flip per-test when exercising replay-gated behaviour. */
override var isReplayTransportAvailable: Boolean = false
- override fun sendToRadio(bytes: ByteArray) {
- sentToRadio.add(bytes)
+ /**
+ * Records [bytes] only while a transport session is admitted. Tests must select a non-null device address and call
+ * [connect] before sending; attempts outside that lifecycle return false and leave [sentToRadio] unchanged.
+ */
+ override fun trySendToRadio(bytes: ByteArray): Boolean {
+ val admittedSession = admitSessionOperation() ?: return false
+ return try {
+ val accepted = !rejectAdmittedSends
+ if (accepted) synchronized(sessionAdmissionLock) { _sentToRadio.add(bytes.copyOf()) }
+ accepted
+ } finally {
+ releaseSessionOperation(admittedSession)
+ }
}
override fun connect() {
@@ -184,6 +187,42 @@ class FakeRadioInterfaceService(override val serviceScope: CoroutineScope = Main
return true
}
+ private fun admitSessionOperation(expectedSession: RadioSessionContext? = null): RadioSessionContext? =
+ synchronized(sessionAdmissionLock) {
+ val active = _activeSession.value
+ val matchesExpectedSession = expectedSession == null || active == expectedSession
+ if (!sessionAdmissionOpen || active == null || !matchesExpectedSession) {
+ null
+ } else {
+ admittedSessionOperations++
+ active
+ }
+ }
+
+ private fun releaseSessionOperation(admittedSession: RadioSessionContext) {
+ val waiter =
+ synchronized(sessionAdmissionLock) {
+ if (_activeSession.value != admittedSession) {
+ sessionLeaseInvariantViolationsState++
+ Logger.e { "Fake session changed before an admitted operation released its lease" }
+ return@synchronized null
+ }
+ if (admittedSessionOperations <= 0) {
+ sessionLeaseInvariantViolationsState++
+ Logger.e { "Fake session operation count underflow" }
+ sessionDrainWaiter.also { sessionDrainWaiter = null }
+ } else {
+ admittedSessionOperations--
+ if (admittedSessionOperations == 0) {
+ sessionDrainWaiter.also { sessionDrainWaiter = null }
+ } else {
+ null
+ }
+ }
+ }
+ waiter?.complete(Unit)
+ }
+
private fun admitSelectedSession() {
val address = _currentDeviceAddressFlow.value
synchronized(sessionAdmissionLock) {
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt
index 47ed6b148d..c76c06cf72 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioTransport.kt
@@ -16,27 +16,53 @@
*/
package org.meshtastic.core.testing
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import org.meshtastic.core.repository.RadioTransport
/** A test double for [RadioTransport] that tracks sent data. */
class FakeRadioTransport : RadioTransport {
- val sentData = mutableListOf<ByteArray>()
- var closeCalled = false
- var closeCount = 0
- private set
+ private val lock = SynchronizedObject()
+ private val mutableSentData = mutableListOf<ByteArray>()
+ val sentData: List<ByteArray>
+ get() = synchronized(lock) { mutableSentData.map { it.copyOf() } }
- var keepAliveCalled = false
+ /** Clears the recorded payload history without changing transport lifecycle state. */
+ fun clearSentData() {
+ synchronized(lock) { mutableSentData.clear() }
+ }
+
+ /** Clears the recorded heartbeat without changing transport lifecycle state. */
+ fun clearKeepAlive() {
+ synchronized(lock) { keepAliveCalledState = false }
+ }
+
+ private var closeCalledState = false
+ val closeCalled: Boolean
+ get() = synchronized(lock) { closeCalledState }
+
+ private var closeCountState = 0
+ val closeCount: Int
+ get() = synchronized(lock) { closeCountState }
+
+ private var keepAliveCalledState = false
+ val keepAliveCalled: Boolean
+ get() = synchronized(lock) { keepAliveCalledState }
- override fun handleSendToRadio(p: ByteArray) {
- sentData.add(p)
+ override fun handleSendToRadio(p: ByteArray): Boolean = synchronized(lock) {
+ if (closeCalledState) return@synchronized false
+ mutableSentData.add(p.copyOf())
+ true
}
override fun keepAlive() {
- keepAliveCalled = true
+ synchronized(lock) { if (!closeCalledState) keepAliveCalledState = true }
}
override suspend fun close() {
- closeCalled = true
- closeCount++
+ synchronized(lock) {
+ closeCalledState = true
+ closeCountState++
+ }
}
}
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt
index ee1ca3907d..509fc67c4e 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeServiceRepository.kt
@@ -26,6 +26,7 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.LockdownState
import org.meshtastic.core.model.service.LockdownTokenInfo
import org.meshtastic.core.model.service.TracerouteResponse
+import org.meshtastic.core.repository.ConnectionStateHolder
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
@@ -33,12 +34,13 @@ import org.meshtastic.proto.MeshPacket
@Suppress("TooManyFunctions")
class FakeServiceRepository : ServiceRepository {
/** Canonical app-level connection state — the single source of truth for UI/feature tests. */
- private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
- override val connectionState: StateFlow<ConnectionState> = _connectionState
+ private val connectionStateHolder = ConnectionStateHolder()
+ override val connectionLifecycle = connectionStateHolder.connectionLifecycle
+ override val connectionState = connectionStateHolder.connectionState
+ override val connectionEpochs = connectionStateHolder.connectionEpochs
- override fun setConnectionState(connectionState: ConnectionState) {
- _connectionState.value = connectionState
- }
+ override fun setConnectionState(connectionState: ConnectionState) =
+ connectionStateHolder.setConnectionState(connectionState)
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt
index 4179b5fe3b..e6f2d28afa 100644
--- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt
+++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/FakeRadioInterfaceServiceSessionTest.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
+import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
@@ -62,6 +63,53 @@ class FakeRadioInterfaceServiceSessionTest {
assertEquals("ble:same", service.activeSession.value?.address)
}
+ @Test
+ fun `trySendToRadio records bytes only while a session is admitted`() = runTest {
+ val service = FakeRadioInterfaceService(serviceScope = backgroundScope)
+ val bytes = byteArrayOf(1, 2, 3)
+
+ assertFalse(service.trySendToRadio(bytes))
+ assertTrue(service.sentToRadio.isEmpty())
+
+ service.setDeviceAddress("ble:test")
+ service.connect()
+ assertTrue(service.trySendToRadio(bytes))
+ assertEquals(1, service.sentToRadio.size)
+ assertTrue(bytes.contentEquals(service.sentToRadio.single()))
+
+ service.disconnect()
+ assertFalse(service.trySendToRadio(bytes))
+ assertEquals(1, service.sentToRadio.size)
+ }
+
+ @Test
+ fun `trySendToRadio can reject an admitted transport handoff without recording bytes`() = runTest {
+ val service = FakeRadioInterfaceService(serviceScope = backgroundScope)
+ service.setDeviceAddress("ble:test")
+ service.connect()
+ service.rejectAdmittedSends = true
+
+ assertFalse(service.trySendToRadio(byteArrayOf(1, 2, 3)))
+ assertTrue(service.sentToRadio.isEmpty())
+ assertEquals(0, service.sessionLeaseInvariantViolations)
+ }
+
+ @Test
+ fun `recorded radio writes are isolated from caller mutation`() = runTest {
+ val service = FakeRadioInterfaceService(serviceScope = backgroundScope)
+ val bytes = byteArrayOf(1, 2, 3)
+ service.setDeviceAddress("ble:test")
+ service.connect()
+
+ assertTrue(service.trySendToRadio(bytes))
+ bytes[0] = 9
+ val recorded = service.sentToRadio.single()
+ assertContentEquals(byteArrayOf(1, 2, 3), recorded)
+
+ recorded[1] = 8
+ assertContentEquals(byteArrayOf(1, 2, 3), service.sentToRadio.single())
+ }
+
@Test
fun `disconnect closes admission and waits for admitted fake session work`() = runTest {
val service = FakeRadioInterfaceService(serviceScope = backgroundScope)
diff --git a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt
index e9c5f8d4e5..9e57c120ed 100644
--- a/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt
+++ b/core/testing/src/commonTest/kotlin/org/meshtastic/core/testing/RepositoryFakesTest.kt
@@ -17,18 +17,39 @@
package org.meshtastic.core.testing
import app.cash.turbine.test
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.QuickChatAction
+import org.meshtastic.core.model.ConnectionEpochs
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.repository.EditSettingsTransactionException
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
import kotlin.test.Test
+import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotSame
import kotlin.test.assertNull
+import kotlin.test.assertSame
import kotlin.test.assertTrue
+import org.meshtastic.core.model.Position as ModelPosition
class RepositoryFakesTest {
@@ -132,6 +153,402 @@ class RepositoryFakesTest {
assertEquals("fresh", manager.lastAssociatedDeviceId)
}
+ @Test
+ fun `service repository fake preserves connection epoch semantics`() {
+ val repository = FakeServiceRepository()
+
+ repository.setConnectionState(ConnectionState.Connected)
+ assertEquals(ConnectionEpochs(completedHandshakes = 1), repository.connectionEpochs.value)
+
+ repository.setConnectionState(ConnectionState.Connected)
+ assertEquals(ConnectionEpochs(completedHandshakes = 1), repository.connectionEpochs.value)
+
+ repository.setConnectionState(ConnectionState.Connecting)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ repository.connectionEpochs.value,
+ )
+
+ repository.setConnectionState(ConnectionState.Connected)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 2,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Connecting,
+ ),
+ repository.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `FakeRadioController preserves configuration and fixed-position evidence until reset`() = runTest {
+ val controller = FakeRadioController()
+ val local = Config(device = Config.DeviceConfig())
+ val admin = Config(lora = Config.LoRaConfig(hop_limit = 5))
+ val module = ModuleConfig(serial = ModuleConfig.SerialConfig(enabled = true))
+ val position = ModelPosition(latitude = 1.0, longitude = 2.0, altitude = 3)
+
+ controller.setLocalConfig(local)
+ controller.setConfig(destNum = 7, config = admin, packetId = 8)
+ controller.setModuleConfig(destNum = 7, config = module, packetId = 9)
+ controller.setFixedPosition(destNum = 7, position = position)
+ controller.setConnectionState(ConnectionState.Connected)
+
+ assertEquals(
+ listOf(
+ FakeRadioController.ConfigWrite(destination = null, config = local),
+ FakeRadioController.ConfigWrite(destination = 7, config = admin),
+ ),
+ controller.configWrites,
+ )
+ assertEquals(listOf(local), controller.localConfigs)
+ assertEquals(local, controller.lastLocalConfig)
+ assertEquals(
+ listOf(FakeRadioController.ModuleConfigWrite(destination = 7, config = module)),
+ controller.moduleConfigWrites,
+ )
+ assertEquals(listOf(module), controller.allModuleConfigs)
+ assertEquals(listOf(7), controller.moduleConfigDestinations)
+ assertEquals(listOf(position), controller.fixedPositions)
+ assertEquals(ConnectionState.Connected, controller.connectionState.value)
+ assertEquals(ConnectionEpochs(completedHandshakes = 1), controller.connectionEpochs.value)
+
+ controller.reset()
+
+ assertTrue(controller.configWrites.isEmpty())
+ assertTrue(controller.localConfigs.isEmpty())
+ assertTrue(controller.moduleConfigWrites.isEmpty())
+ assertTrue(controller.allModuleConfigs.isEmpty())
+ assertTrue(controller.moduleConfigDestinations.isEmpty())
+ assertTrue(controller.fixedPositions.isEmpty())
+ assertEquals(ConnectionState.Disconnected, controller.connectionState.value)
+ assertEquals(
+ ConnectionEpochs(
+ departures = 1,
+ completedHandshakes = 1,
+ handshakesAtLastDeparture = 1,
+ lastDepartureState = ConnectionState.Disconnected,
+ ),
+ controller.connectionEpochs.value,
+ )
+ }
+
+ @Test
+ fun `FakeRadioController uses null destination consistently for local config writes`() = runTest {
+ val controller = FakeRadioController()
+ val config = Config(device = Config.DeviceConfig())
+ val moduleConfig = ModuleConfig(serial = ModuleConfig.SerialConfig(enabled = true))
+
+ controller.setConfig(destNum = 0, config = config, packetId = 1)
+ controller.setModuleConfig(destNum = 0, config = moduleConfig, packetId = 2)
+ controller.editLocalSettings {
+ setConfig(config)
+ setModuleConfig(moduleConfig)
+ }
+
+ assertEquals(
+ List(2) { FakeRadioController.ConfigWrite(destination = null, config = config) },
+ controller.configWrites,
+ )
+ assertEquals(
+ List(2) { FakeRadioController.ModuleConfigWrite(destination = null, config = moduleConfig) },
+ controller.moduleConfigWrites,
+ )
+ assertEquals(List(2) { moduleConfig }, controller.localModuleConfigs)
+ assertEquals(List<Int?>(2) { null }, controller.moduleConfigDestinations)
+ }
+
+ @Test
+ fun `FakeCommandSender mirrors queued status after successful admission`() = runTest {
+ val sender = FakeCommandSender()
+ val packet =
+ DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value).apply { errorMessage = "original" }
+
+ sender.sendData(packet)
+
+ assertEquals(MessageStatus.QUEUED, packet.status)
+ assertTrue(packet.id != 0)
+ assertTrue(packet.time > 0)
+ val stored = sender.sentPackets.single()
+ assertNotSame(packet, stored)
+ assertEquals(packet, stored)
+ assertEquals("original", stored.errorMessage)
+
+ val storedId = stored.id
+ packet.id = storedId + 1
+ packet.errorMessage = "caller mutation"
+ stored.status = MessageStatus.ERROR
+ assertEquals(storedId, sender.sentPackets.single().id)
+ assertEquals(MessageStatus.QUEUED, sender.sentPackets.single().status)
+ assertEquals("original", sender.sentPackets.single().errorMessage)
+ }
+
+ @Test
+ fun `FakeCommandSender rejects data packets without a port number before assigning an id`() = runTest {
+ val sender = FakeCommandSender()
+ val packet = DataPacket(bytes = null, dataType = 0)
+ val nextPacketId = sender.getCurrentPacketId()
+
+ assertFailsWith<IllegalArgumentException> { sender.sendData(packet) }
+ assertEquals(0, packet.id)
+ assertEquals(nextPacketId, sender.getCurrentPacketId())
+ assertTrue(sender.sentPackets.isEmpty())
+ }
+
+ @Test
+ fun `FakeCommandSender mirrors rejected data admission`() = runTest {
+ val sender = FakeCommandSender()
+ val rejection = PacketQueueRejectedException("Test packet")
+ val packet = DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value, time = 123L)
+ sender.sendDataFailure = rejection
+
+ val failure = assertFailsWith<PacketQueueRejectedException> { sender.sendData(packet) }
+
+ assertSame(rejection, failure)
+ assertEquals(MessageStatus.ERROR, packet.status)
+ assertEquals(123L, packet.time)
+ assertTrue(sender.sentPackets.isEmpty())
+ }
+
+ @Test
+ fun `FakeCommandSender records retryable admin and telemetry requests and clears them on reset`() = runTest {
+ val sender = FakeCommandSender()
+ val adminMessage = AdminMessage(get_device_metadata_request = true)
+
+ sender.sendAdmin(destNum = 123, requestId = 7, wantResponse = true) { adminMessage }
+ sender.requestTelemetry(requestId = 8, destNum = 456, typeValue = 2)
+
+ assertEquals(
+ listOf(FakeCommandSender.AdminRequest(123, 7, wantResponse = true, adminMessage)),
+ sender.adminRequests,
+ )
+ assertEquals(listOf(FakeCommandSender.TelemetryRequest(8, 456, 2)), sender.telemetryRequests)
+
+ sender.reset()
+
+ assertTrue(sender.adminRequests.isEmpty())
+ assertTrue(sender.telemetryRequests.isEmpty())
+ }
+
+ @Test
+ fun `FakeCommandSender accepts local-node failures on command paths`() = runTest {
+ val sender = FakeCommandSender()
+ sender.commandFailure = LocalNodeUnavailableException("Test command")
+
+ assertFailsWith<LocalNodeUnavailableException> { sender.requestUserInfo(123) }
+ }
+
+ @Test
+ fun `FakeCommandSender applies command failure before data mutation`() = runTest {
+ val failure = LocalNodeUnavailableException("Test command")
+ val sender = FakeCommandSender().apply { commandFailure = failure }
+ val packet = DataPacket(bytes = null, dataType = PortNum.TEXT_MESSAGE_APP.value)
+
+ assertSame(failure, assertFailsWith<LocalNodeUnavailableException> { sender.sendData(packet) })
+ assertEquals(0, packet.id)
+ assertTrue(sender.sentPackets.isEmpty())
+ }
+
+ @Test
+ fun `FakeCommandSender applies command failure before immediate command mutation`() {
+ val failure = PacketQueueRejectedException("queue closed")
+ val sender = FakeCommandSender().apply { commandFailure = failure }
+ val message = AdminMessage(set_time_only = 123)
+
+ assertSame(failure, assertFailsWith<PacketQueueRejectedException> { sender.sendAdminImmediate(7) { message } })
+ assertSame(
+ failure,
+ assertFailsWith<PacketQueueRejectedException> {
+ sender.sendLockdownPassphrase("secret", boots = 1, hours = 2, maxSessionSeconds = 3, disable = false)
+ },
+ )
+ assertSame(failure, assertFailsWith<PacketQueueRejectedException> { sender.sendLockNow() })
+
+ assertTrue(sender.adminRequests.isEmpty())
+ assertNull(sender.lastPassphrase)
+ assertFalse(sender.lockNowCalled)
+ }
+
+ @Test
+ fun `FakeCommandSender records connection ownership on post-handshake requests`() = runTest {
+ val sender = FakeCommandSender()
+
+ sender.sendAdminForConnection(destNum = 123, expectedConnectionVersion = 41, requestId = 7) { AdminMessage() }
+ sender.requestTelemetryForConnection(
+ requestId = 8,
+ destNum = 123,
+ typeValue = 2,
+ expectedConnectionVersion = 41,
+ )
+
+ assertEquals(41L, sender.adminRequests.single().expectedConnectionVersion)
+ assertEquals(41L, sender.telemetryRequests.single().expectedConnectionVersion)
+ }
+
+ @Test
+ fun `FakeRadioTransport is terminal after close`() = runTest {
+ val transport = FakeRadioTransport()
+ val outbound = byteArrayOf(1, 2, 3)
+
+ assertTrue(transport.handleSendToRadio(outbound))
+ outbound[0] = 9
+ val recorded = transport.sentData.single()
+ recorded[1] = 9
+ transport.close()
+ transport.clearKeepAlive()
+
+ assertFalse(transport.handleSendToRadio(byteArrayOf(4, 5, 6)))
+ transport.keepAlive()
+ assertFalse(transport.keepAliveCalled)
+ assertEquals(1, transport.sentData.size)
+ assertContentEquals(byteArrayOf(1, 2, 3), transport.sentData.single())
+ }
+
+ @Test
+ fun `FakeRadioController scopes standalone hooks per write`() = runTest {
+ val controller = FakeRadioController()
+ val transactionStarted = CompletableDeferred<Unit>()
+ val finishTransaction = CompletableDeferred<Unit>()
+ val standaloneHooks = mutableListOf<String>()
+ controller.onStandaloneConfig = { standaloneHooks += "config" }
+ controller.onStandaloneModuleConfig = { standaloneHooks += "module" }
+
+ val transaction = async {
+ controller.editSettings(destNum = 7) {
+ transactionStarted.complete(Unit)
+ finishTransaction.await()
+ setConfig(Config(device = Config.DeviceConfig()))
+ setModuleConfig(ModuleConfig(serial = ModuleConfig.SerialConfig(enabled = true)))
+ }
+ }
+ transactionStarted.await()
+
+ controller.setConfig(destNum = 8, config = Config(power = Config.PowerConfig()), packetId = 1)
+ controller.setModuleConfig(
+ destNum = 8,
+ config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)),
+ packetId = 2,
+ )
+ finishTransaction.complete(Unit)
+ transaction.await()
+
+ assertEquals(listOf("config", "module"), standaloneHooks)
+ assertEquals(
+ listOf(
+ FakeRadioController.ConfigWrite(destination = 8, config = Config(power = Config.PowerConfig())),
+ FakeRadioController.ConfigWrite(destination = 7, config = Config(device = Config.DeviceConfig())),
+ ),
+ controller.configWrites,
+ )
+ assertEquals(
+ listOf(
+ FakeRadioController.ModuleConfigWrite(
+ destination = 8,
+ config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)),
+ ),
+ FakeRadioController.ModuleConfigWrite(
+ destination = 7,
+ config = ModuleConfig(serial = ModuleConfig.SerialConfig(enabled = true)),
+ ),
+ ),
+ controller.moduleConfigWrites,
+ )
+ assertEquals(
+ listOf("begin", "config:update", "module:update", "config:update", "module:update", "commit"),
+ controller.adminOperations,
+ )
+ }
+
+ @Test
+ fun `FakeRadioController records owner destinations consistently`() = runTest {
+ val controller = FakeRadioController()
+ val localOwner = User(id = "!00000001", long_name = "Local")
+ val remoteOwner = User(id = "!00000002", long_name = "Remote")
+
+ controller.setOwner(destNum = 0, user = localOwner, packetId = 1)
+ controller.setOwner(destNum = 7, user = remoteOwner, packetId = 2)
+
+ assertEquals(
+ listOf(
+ FakeRadioController.OwnerWrite(destination = null, user = localOwner),
+ FakeRadioController.OwnerWrite(destination = 7, user = remoteOwner),
+ ),
+ controller.ownerWrites,
+ )
+
+ controller.reset()
+ assertTrue(controller.ownerWrites.isEmpty())
+ }
+
+ @Test
+ fun `FakeRadioController rejects edit settings before running writes when begin fails`() = runTest {
+ val controller = FakeRadioController().apply { failEditSettingsBegin = true }
+ var blockRan = false
+
+ assertFailsWith<EditSettingsTransactionException> { controller.editLocalSettings { blockRan = true } }
+
+ assertFalse(blockRan)
+ assertTrue(controller.editSettingsCalled)
+ assertEquals(listOf(0), controller.editSettingsDestinations)
+ assertTrue(controller.adminOperations.isEmpty())
+ }
+
+ @Test
+ fun `FakeRadioController reset clears begin-boundary failure hook`() {
+ val controller = FakeRadioController().apply { failEditSettingsBegin = true }
+
+ controller.reset()
+
+ assertFalse(controller.failEditSettingsBegin)
+ }
+
+ @Test
+ fun `FakeRadioController commits after block failure and preserves failure precedence`() = runTest {
+ val controller = FakeRadioController()
+ val blockFailure = IllegalStateException("write failed")
+ val commitFailure = IllegalArgumentException("commit failed")
+ controller.onEditSettingsCommitted = { throw commitFailure }
+
+ val failure = assertFailsWith<IllegalStateException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertEquals(listOf(commitFailure.message), failure.suppressedExceptions.map(Throwable::message))
+ assertIs<IllegalArgumentException>(failure.suppressedExceptions.single())
+ assertEquals(listOf("begin", "commit"), controller.adminOperations)
+ }
+
+ @Test
+ fun `FakeRadioController preserves a distinct same-looking commit failure`() = runTest {
+ val controller = FakeRadioController()
+ val blockFailure = IllegalStateException("same recovered failure")
+ controller.onEditSettingsCommitted = { throw IllegalStateException("same recovered failure") }
+
+ val failure = assertFailsWith<IllegalStateException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertEquals(1, failure.suppressedExceptions.size)
+ assertNotSame(blockFailure, failure.suppressedExceptions.single())
+ assertEquals(blockFailure.message, failure.suppressedExceptions.single().message)
+ }
+
+ @Test
+ fun `FakeRadioController does not suppress a recovered copy of the block failure`() = runTest {
+ val controller = FakeRadioController()
+ val blockFailure = IllegalStateException("same recovered failure")
+ controller.onEditSettingsCommitted = { throw blockFailure }
+
+ val failure = assertFailsWith<IllegalStateException> { controller.editLocalSettings { throw blockFailure } }
+
+ assertSame(blockFailure, failure)
+ assertTrue(failure.suppressedExceptions.isEmpty())
+ }
+
@Test
fun `FakeRadioConfigRepository tracks channel set and module config`() = runTest {
val repo = FakeRadioConfigRepository()
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
index ecfb76aba8..997bfb9e7c 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ProtoExtensions.kt
@@ -104,11 +104,15 @@ fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List
* persist/reload path runs once at commit.) Writing LoRa inside the same session mirrors `InstallProfileUseCase` and is
* why the old pre/post settle delays are gone: the begin/commit boundary is the settle.
*
- * The local channel cache is commit-shaped: transactional channel writes deliberately do not mirror per slot (see
- * `AdminControllerImpl.EditSettingsSession.setChannel`), and this function replaces the cached channel list once, after
- * the session succeeds — so an import interrupted before that point leaves the local channel cache untouched. The
- * post-commit [RadioConfigRepository.updateChannelSet] call updates the normalized settings and imported LoRa config
- * together; an import without LoRa preserves the cached LoRa config.
+ * The local caches are commit-shaped: transactional channel writes deliberately do not mirror per slot (see
+ * `AdminControllerImpl.EditSettingsSession.setChannel`), while transactional `setConfig` stages its LoRa projection
+ * until commit acceptance. This function replaces the cached channel list only after the edit session succeeds, so an
+ * import that fails at a transaction boundary leaves both channel and LoRa cache state describing the last committed
+ * device configuration. The post-commit [RadioConfigRepository.updateChannelSet] call updates the normalized settings
+ * and imported LoRa config together; an import without LoRa preserves the cached LoRa config. Cancellation during
+ * `editLocalSettings` can leave a committed transaction without running the cache replacement, so the cache lags the
+ * device until the next config read. Cancellation after the edit returns does not skip replacement because that final
+ * cache update runs under `NonCancellable`.
*
* Imported settings are normalized before any write or bounds check, so blank placeholder secondaries and semantic
* duplicates never reach the radio or the local cache.
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
index b36e93f3c9..d7f7d13b28 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/radio/DesktopRadioTransportFactory.kt
@@ -64,7 +64,7 @@ class DesktopRadioTransportFactory(
}
address.startsWith(InterfaceId.SERIAL.id) -> {
- SerialTransport.open(
+ SerialTransport.create(
portName = address.removePrefix(InterfaceId.SERIAL.id.toString()),
callback = service,
scope = service.serviceScope,
diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
index c2b001cfb4..e7a0627551 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt
@@ -92,8 +92,9 @@ class NoopRadioInterfaceService : RadioInterfaceService {
override val meshActivity: Flow<MeshActivity> = MutableSharedFlow<MeshActivity>()
override val connectionError: Flow<String> = MutableSharedFlow<String>()
- override fun sendToRadio(bytes: ByteArray) {
- logWarn("NoopRadioInterfaceService.sendToRadio(${bytes.size} bytes)")
+ override fun trySendToRadio(bytes: ByteArray): Boolean {
+ logWarn("NoopRadioInterfaceService.trySendToRadio(${bytes.size} bytes)")
+ return false
}
override fun resetReceivedBuffer() {
diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
index 505acc73da..5b7fee1153 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
@@ -418,8 +418,14 @@ class DiscoveryScanEngine(
private suspend fun requestNeighborInfoAtDwellBoundary() {
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: return
val packetId = radioController.generatePacketId()
- radioController.requestNeighborInfo(packetId, myNodeNum)
- Logger.d { "DiscoveryScanEngine: requested NeighborInfo from local node $myNodeNum (packetId=$packetId)" }
+ try {
+ radioController.requestNeighborInfo(packetId, myNodeNum)
+ Logger.d { "DiscoveryScanEngine: requested NeighborInfo from local node $myNodeNum (packetId=$packetId)" }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "DiscoveryScanEngine: NeighborInfo request failed; continuing the dwell" }
+ }
}
private suspend fun runDwell(presetName: String, durationSeconds: Long): Boolean {
diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
index f0be3acee9..53e45eed1f 100644
--- a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
+++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
@@ -45,6 +45,8 @@ import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.testing.FakeMeshPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioConfigRepository
@@ -595,6 +597,38 @@ class DiscoveryScanEngineTest {
)
}
+ @Test
+ fun neighborRequestQueueRejectionKeepsBestEffortScanRunning() = runTest {
+ nodeRepository.setMyNodeInfo(createMyNodeInfo())
+ radioController.requestNeighborInfoFailure = PacketQueueRejectedException("Neighbor info")
+ val engine = createEngine(this)
+
+ engine.startScan(listOf(ChannelOption.SHORT_FAST), dwellDurationSeconds = 1)
+ advanceUntilIdle()
+
+ val state = engine.scanState.value
+ assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
+ assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
+ assertEquals(1, radioController.neighborInfoRequests.size)
+ assertEquals("complete", discoveryDao.sessions.values.single().completionStatus)
+ }
+
+ @Test
+ fun localIdentityLossDuringNeighborRequestKeepsBestEffortScanRunning() = runTest {
+ nodeRepository.setMyNodeInfo(createMyNodeInfo())
+ radioController.requestNeighborInfoFailure = LocalNodeUnavailableException("Neighbor info")
+ val engine = createEngine(this)
+
+ engine.startScan(listOf(ChannelOption.SHORT_FAST), dwellDurationSeconds = 1)
+ advanceUntilIdle()
+
+ val state = engine.scanState.value
+ assertTrue(state is DiscoveryScanState.Complete, "expected Complete, was $state")
+ assertEquals(DiscoveryScanState.CompletionOutcome.Success, (state as DiscoveryScanState.Complete).outcome)
+ assertEquals(1, radioController.neighborInfoRequests.size)
+ assertEquals("complete", discoveryDao.sessions.values.single().completionStatus)
+ }
+
// region Home-preset restoration (the one config-mutating, safety-critical behavior of a scan)
@Test
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
index f2420e8858..69b0ed9005 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
@@ -25,6 +25,8 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
@@ -49,6 +51,7 @@ constructor(
private val radioController: RadioController,
private val snackbarManager: SnackbarManager,
private val analytics: PlatformAnalytics,
+ private val resolveUiText: suspend (UiText) -> String = { it.resolve() },
) : NodeRequestActions {
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
@@ -58,17 +61,32 @@ constructor(
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
private suspend fun showFeedback(text: UiText) {
- snackbarManager.showSnackbar(message = text.resolve())
+ snackbarManager.showSnackbar(message = resolveUiText(text))
}
- override suspend fun requestUserInfo(destNum: Int, longName: String) {
+ private suspend fun runRequest(block: suspend () -> Unit) {
+ try {
+ block()
+ } catch (e: PacketQueueRejectedException) {
+ showNodeRequestFailure(e, "Node request rejected by outbound packet queue", snackbarManager, resolveUiText)
+ } catch (e: LocalNodeUnavailableException) {
+ showNodeRequestFailure(
+ e,
+ "Node request deferred until local node identity is available",
+ snackbarManager,
+ resolveUiText,
+ )
+ }
+ }
+
+ override suspend fun requestUserInfo(destNum: Int, longName: String) = runRequest {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
analytics.trackAction("user_info_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
}
- override suspend fun requestNeighborInfo(destNum: Int, longName: String) {
+ override suspend fun requestNeighborInfo(destNum: Int, longName: String) = runRequest {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestNeighborInfo(packetId, destNum)
@@ -76,14 +94,14 @@ constructor(
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName))
}
- override suspend fun requestPosition(destNum: Int, longName: String, position: Position) {
+ override suspend fun requestPosition(destNum: Int, longName: String, position: Position) = runRequest {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
analytics.trackAction("position_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
}
- override suspend fun requestTelemetry(destNum: Int, longName: String, type: TelemetryType) {
+ override suspend fun requestTelemetry(destNum: Int, longName: String, type: TelemetryType) = runRequest {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
@@ -103,7 +121,7 @@ constructor(
showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName))
}
- override suspend fun requestTraceroute(destNum: Int, longName: String) {
+ override suspend fun requestTraceroute(destNum: Int, longName: String) = runRequest {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTraceroute(packetId, destNum)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index 14681bd34f..354f91f348 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -29,7 +29,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
import org.meshtastic.core.domain.usecase.session.EnsureSessionResult
@@ -39,12 +38,15 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.QueryController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.connect_radio_for_remote_admin
import org.meshtastic.core.resources.remote_admin_unreachable
import org.meshtastic.core.ui.util.SnackbarManager
+import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
@@ -52,6 +54,10 @@ import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
+private const val QUEUE_REJECTION_LOG_MESSAGE = "Node-detail request rejected by outbound packet queue"
+private const val LOCAL_NODE_UNAVAILABLE_LOG_MESSAGE =
+ "Node-detail request deferred until local node identity is available"
+
/** UI state for the Node Details screen. */
@androidx.compose.runtime.Stable
data class NodeDetailUiState(
@@ -67,10 +73,6 @@ data class NodeDetailUiState(
val isEnsuringSession: Boolean = false,
)
-internal object NodeDetailUiTextResolver {
- var resolve: suspend (UiText) -> String = { it.resolve() }
-}
-
/**
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@@ -86,6 +88,7 @@ class NodeDetailViewModel(
private val ensureRemoteAdminSession: EnsureRemoteAdminSessionUseCase,
private val observeRemoteAdminSessionStatus: ObserveRemoteAdminSessionStatusUseCase,
private val snackbarManager: SnackbarManager,
+ private val resolveUiText: suspend (UiText) -> String = { it.resolve() },
) : ViewModel() {
private val nodeIdFromRoute: Int? = savedStateHandle.get<Int>("destNum")
@@ -143,27 +146,27 @@ class NodeDetailViewModel(
is NodeMenuAction.Favorite -> nodeManagementActions.requestFavoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
- viewModelScope.launch {
+ safeLaunch(tag = "requestUserInfo") {
nodeRequestActions.requestUserInfo(action.node.num, action.node.user.long_name)
}
is NodeMenuAction.RequestNeighborInfo ->
- viewModelScope.launch {
+ safeLaunch(tag = "requestNeighborInfo") {
nodeRequestActions.requestNeighborInfo(action.node.num, action.node.user.long_name)
}
is NodeMenuAction.RequestPosition ->
- viewModelScope.launch {
+ safeLaunch(tag = "requestPosition") {
nodeRequestActions.requestPosition(action.node.num, action.node.user.long_name)
}
is NodeMenuAction.RequestTelemetry ->
- viewModelScope.launch {
+ safeLaunch(tag = "requestTelemetry") {
nodeRequestActions.requestTelemetry(action.node.num, action.node.user.long_name, action.type)
}
is NodeMenuAction.TraceRoute ->
- viewModelScope.launch {
+ safeLaunch(tag = "requestTraceroute") {
nodeRequestActions.requestTraceroute(action.node.num, action.node.user.long_name)
}
@@ -174,7 +177,15 @@ class NodeDetailViewModel(
/**
* Re-fetch device metadata (firmware/edition/role) for [destNum]. Refreshes the session passkey as a side effect.
*/
- fun refreshMetadata(destNum: Int) = viewModelScope.launch { queryController.refreshMetadata(destNum) }
+ fun refreshMetadata(destNum: Int) = safeLaunch(tag = "refreshMetadata") {
+ try {
+ queryController.refreshMetadata(destNum)
+ } catch (e: PacketQueueRejectedException) {
+ showNodeRequestFailure(e, QUEUE_REJECTION_LOG_MESSAGE, snackbarManager, resolveUiText)
+ } catch (e: LocalNodeUnavailableException) {
+ showNodeRequestFailure(e, LOCAL_NODE_UNAVAILABLE_LOG_MESSAGE, snackbarManager, resolveUiText)
+ }
+ }
/**
* Ensure a remote-admin session passkey is fresh, then request navigation to the remote-admin screen. Surfaces a
@@ -183,7 +194,7 @@ class NodeDetailViewModel(
fun openRemoteAdmin(destNum: Int) {
// Atomic check-and-flip prevents a double-tap from queuing two passkey exchanges + two navigation events.
if (!isEnsuringSession.compareAndSet(expect = false, update = true)) return
- viewModelScope.launch {
+ safeLaunch(tag = "openRemoteAdmin") {
try {
when (ensureRemoteAdminSession(destNum)) {
EnsureSessionResult.AlreadyActive,
@@ -192,14 +203,18 @@ class NodeDetailViewModel(
EnsureSessionResult.Disconnected -> {
val text = Res.string.connect_radio_for_remote_admin
- snackbarManager.showSnackbar(NodeDetailUiTextResolver.resolve(UiText.Resource(text)))
+ snackbarManager.showSnackbar(resolveUiText(UiText.Resource(text)))
}
EnsureSessionResult.Timeout ->
snackbarManager.showSnackbar(
- NodeDetailUiTextResolver.resolve(UiText.Resource(Res.string.remote_admin_unreachable)),
+ resolveUiText(UiText.Resource(Res.string.remote_admin_unreachable)),
)
}
+ } catch (e: PacketQueueRejectedException) {
+ showNodeRequestFailure(e, QUEUE_REJECTION_LOG_MESSAGE, snackbarManager, resolveUiText)
+ } catch (e: LocalNodeUnavailableException) {
+ showNodeRequestFailure(e, LOCAL_NODE_UNAVAILABLE_LOG_MESSAGE, snackbarManager, resolveUiText)
} finally {
isEnsuringSession.value = false
}
@@ -207,7 +222,7 @@ class NodeDetailViewModel(
}
fun setNodeNotes(nodeNum: Int, notes: String) {
- viewModelScope.launch { nodeManagementActions.setNodeNotes(nodeNum, notes) }
+ safeLaunch(tag = "setNodeNotes") { nodeManagementActions.setNodeNotes(nodeNum, notes) }
}
/** Returns the type-safe navigation route for a direct message to this node. */
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
index 7736de583a..991c416c0c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -17,15 +17,21 @@
package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.handledLaunch
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.favorite_add
import org.meshtastic.core.resources.favorite_remove
@@ -39,7 +45,7 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_node_text
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.ui.util.AlertManager
-import kotlin.coroutines.cancellation.CancellationException
+import org.meshtastic.core.ui.util.SnackbarManager
@Single
open class NodeManagementActions
@@ -48,14 +54,15 @@ constructor(
private val radioController: RadioController,
private val alertManager: AlertManager,
private val analytics: PlatformAnalytics,
+ private val snackbarManager: SnackbarManager,
+ private val resolveUiText: suspend (UiText) -> String = { it.resolve() },
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
alertManager.showAlert(
titleRes = Res.string.remove,
messageRes = Res.string.remove_node_text,
onConfirm = {
- scope.launch { removeNode(node.num) }
- onAfterRemove()
+ launchRadioMutation(scope, "removeNode", onSuccess = onAfterRemove) { removeNode(node.num) }
},
)
}
@@ -74,7 +81,7 @@ constructor(
alertManager.showAlert(
titleRes = Res.string.ignore,
message = message,
- onConfirm = { scope.launch { setIgnored(node.num, !node.isIgnored) } },
+ onConfirm = { launchRadioMutation(scope, "setIgnored") { setIgnored(node.num, !node.isIgnored) } },
)
}
}
@@ -90,7 +97,7 @@ constructor(
alertManager.showAlert(
titleRes = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
message = message,
- onConfirm = { scope.launch { toggleMuted(node.num) } },
+ onConfirm = { launchRadioMutation(scope, "toggleMuted") { toggleMuted(node.num) } },
)
}
}
@@ -109,7 +116,7 @@ constructor(
alertManager.showAlert(
titleRes = Res.string.favorite,
message = message,
- onConfirm = { scope.launch { setFavorite(node.num, !node.isFavorite) } },
+ onConfirm = { launchRadioMutation(scope, "setFavorite") { setFavorite(node.num, !node.isFavorite) } },
)
}
}
@@ -119,13 +126,42 @@ constructor(
analytics.trackAction("node_favorite", mapOf("favorite" to favorite))
}
- open suspend fun setNodeNotes(nodeNum: Int, notes: String) {
- try {
- nodeRepository.setNodeNotes(nodeNum, notes)
- } catch (ex: CancellationException) {
- throw ex
- } catch (ex: Exception) {
- Logger.e(ex) { "Set node notes error" }
+ private fun launchRadioMutation(
+ scope: CoroutineScope,
+ operation: String,
+ onSuccess: () -> Unit = {},
+ block: suspend () -> Unit,
+ ) {
+ scope.handledLaunch {
+ val succeeded =
+ try {
+ block()
+ true
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: PacketQueueRejectedException) {
+ showNodeRequestFailure(
+ e,
+ "Node management operation '$operation' rejected by outbound packet queue",
+ snackbarManager,
+ resolveUiText,
+ )
+ false
+ } catch (e: LocalNodeUnavailableException) {
+ showNodeRequestFailure(
+ e,
+ "Node management operation '$operation' deferred until local node identity is available",
+ snackbarManager,
+ resolveUiText,
+ )
+ false
+ }
+ if (succeeded) onSuccess()
}
}
+
+ open suspend fun setNodeNotes(nodeNum: Int, notes: String) {
+ val failure = safeCatching { nodeRepository.setNodeNotes(nodeNum, notes) }.exceptionOrNull()
+ if (failure != null) Logger.e(failure) { "Set node notes error" }
+ }
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestRejectionFeedback.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestRejectionFeedback.kt
new file mode 100644
index 0000000000..99ff031646
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeRequestRejectionFeedback.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.detail
+
+import co.touchlab.kermit.Logger
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.node_request_send_failed
+import org.meshtastic.core.ui.util.SnackbarManager
+
+/** Logs one expected node-request availability failure and presents the shared localized failure message. */
+internal suspend fun showNodeRequestFailure(
+ failure: Throwable,
+ operation: String,
+ snackbarManager: SnackbarManager,
+ resolveUiText: suspend (UiText) -> String,
+) {
+ Logger.w(failure) { operation }
+ snackbarManager.showSnackbar(resolveUiText(UiText.Resource(Res.string.node_request_send_failed)))
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index 5cb1c94ab4..47aa3ba22e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -42,6 +42,7 @@ import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.detail.NodeRequestActions
@@ -187,7 +188,7 @@ class NodeListViewModel(
nodeFilterPreferences.setNodeSort(sort)
}
- fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
+ fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newLoraConfig = channelSet.lora_config
if (newLoraConfig != null) {
@@ -213,7 +214,7 @@ class NodeListViewModel(
/** Initiates a trace route request to the specified node. */
fun traceRoute(node: Node) {
- viewModelScope.launch { nodeRequestActions.requestTraceroute(node.num, node.user.long_name) }
+ safeLaunch(tag = "requestTraceroute") { nodeRequestActions.requestTraceroute(node.num, node.user.long_name) }
}
companion object {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index c335ec1162..a07555526e 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -20,7 +20,6 @@ import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -31,7 +30,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import okio.ByteString.Companion.decodeBase64
@@ -265,13 +263,15 @@ open class MetricsViewModel(
fun requestPosition() {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
- viewModelScope.launch { nodeRequestActions.requestPosition(it, state.value.node?.user?.long_name ?: "") }
+ safeLaunch(tag = "requestPosition") {
+ nodeRequestActions.requestPosition(it, state.value.node?.user?.long_name ?: "")
+ }
}
}
fun requestTelemetry(type: TelemetryType) {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
- viewModelScope.launch {
+ safeLaunch(tag = "requestTelemetry") {
nodeRequestActions.requestTelemetry(it, state.value.node?.user?.long_name ?: "", type)
}
}
@@ -279,13 +279,15 @@ open class MetricsViewModel(
fun requestTraceroute() {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
- viewModelScope.launch { nodeRequestActions.requestTraceroute(it, state.value.node?.user?.long_name ?: "") }
+ safeLaunch(tag = "requestTraceroute") {
+ nodeRequestActions.requestTraceroute(it, state.value.node?.user?.long_name ?: "")
+ }
}
}
fun requestNeighborInfo() {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
- viewModelScope.launch {
+ safeLaunch(tag = "requestNeighborInfo") {
nodeRequestActions.requestNeighborInfo(it, state.value.node?.user?.long_name ?: "")
}
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActionsTest.kt
new file mode 100644
index 0000000000..7990252753
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActionsTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.detail
+
+import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.mock
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import org.meshtastic.core.repository.PlatformAnalytics
+import org.meshtastic.core.repository.RadioController
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class CommonNodeRequestActionsTest {
+ private val radioController: RadioController = mock()
+ private val snackbarManager = RecordingSnackbarManager()
+ private val actions =
+ CommonNodeRequestActions(
+ radioController = radioController,
+ snackbarManager = snackbarManager,
+ analytics = mock<PlatformAnalytics>(),
+ resolveUiText = resolveNodeRequestFailureUiText,
+ )
+
+ @BeforeTest
+ fun setUp() {
+ snackbarManager.messages.clear()
+ }
+
+ @Test
+ fun `missing local identity uses the same localized request feedback`() = runTest {
+ everySuspend { radioController.requestUserInfo(1234) } throws LocalNodeUnavailableException("local node")
+
+ actions.requestUserInfo(destNum = 1234, longName = "Test Node")
+
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ }
+
+ @Test
+ fun `traceroute queue rejection is surfaced without starting the request timer`() = runTest {
+ val rejection = PacketQueueRejectedException("Traceroute request")
+ every { radioController.generatePacketId() } returns 42
+ everySuspend { radioController.requestTraceroute(42, 1234) } throws rejection
+
+ actions.requestTraceroute(destNum = 1234, longName = "Test Node")
+
+ assertNull(actions.lastTracerouteTime.value)
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ }
+
+ @Test
+ fun `neighbor queue rejection is surfaced without starting the request timer`() = runTest {
+ val rejection = PacketQueueRejectedException("Neighbor info request")
+ every { radioController.generatePacketId() } returns 43
+ everySuspend { radioController.requestNeighborInfo(43, 1234) } throws rejection
+
+ actions.requestNeighborInfo(destNum = 1234, longName = "Test Node")
+
+ assertEquals(emptyMap(), actions.lastRequestNeighborTimes.value)
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ }
+}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt
index e1bf663be2..0b490904dc 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModelTest.kt
@@ -16,15 +16,16 @@
*/
package org.meshtastic.feature.node.detail
-import androidx.compose.material3.SnackbarDuration
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -42,12 +43,9 @@ import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatu
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.SessionStatus
import org.meshtastic.core.navigation.SettingsRoute
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
import org.meshtastic.core.repository.QueryController
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.UiText
-import org.meshtastic.core.resources.connect_radio_for_remote_admin
-import org.meshtastic.core.resources.remote_admin_unreachable
-import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.proto.User
@@ -77,19 +75,6 @@ class NodeDetailViewModelTest {
every { getNodeDetailsUseCase(any()) } returns emptyFlow()
every { observeRemoteAdminSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
snackbarManager.messages.clear()
- NodeDetailUiTextResolver.resolve = { text ->
- when (text) {
- is UiText.DynamicString -> text.value
-
- is UiText.Resource ->
- when (text.res) {
- Res.string.connect_radio_for_remote_admin -> "Connect to a radio to administer remote nodes."
- Res.string.remote_admin_unreachable -> "Could not reach node — try again or move closer."
- else -> error("Unexpected UiText resource in test: ${text.res}")
- }
- }
- }
-
viewModel = createViewModel(1234)
}
@@ -102,25 +87,11 @@ class NodeDetailViewModelTest {
ensureRemoteAdminSession = ensureRemoteAdminSession,
observeRemoteAdminSessionStatus = observeRemoteAdminSessionStatus,
snackbarManager = snackbarManager,
+ resolveUiText = resolveNodeDetailUiTextForTest,
)
- private class RecordingSnackbarManager : SnackbarManager() {
- val messages = mutableListOf<String>()
-
- override fun showSnackbar(
- message: String,
- actionLabel: String?,
- withDismissAction: Boolean,
- duration: SnackbarDuration,
- onAction: (() -> Unit)?,
- ) {
- messages += message
- }
- }
-
@AfterTest
fun tearDown() {
- NodeDetailUiTextResolver.resolve = { it.resolve() }
Dispatchers.resetMain()
}
@@ -202,4 +173,42 @@ class NodeDetailViewModelTest {
assertEquals(listOf(expectedMessage), snackbarManager.messages)
verifySuspend { ensureRemoteAdminSession(1234) }
}
+
+ @Test
+ fun `refreshMetadata surfaces queue rejection without throwing from view model scope`() = runTest(testDispatcher) {
+ everySuspend { queryController.refreshMetadata(1234) } throws
+ PacketQueueRejectedException("Metadata request")
+
+ viewModel.refreshMetadata(1234)
+ runCurrent()
+
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ verifySuspend { queryController.refreshMetadata(1234) }
+ }
+
+ @Test
+ fun `openRemoteAdmin surfaces queue rejection and releases the in-flight guard`() = runTest(testDispatcher) {
+ everySuspend { ensureRemoteAdminSession(1234) } throws PacketQueueRejectedException("Metadata request")
+
+ viewModel.openRemoteAdmin(1234)
+ runCurrent()
+ viewModel.openRemoteAdmin(1234)
+ runCurrent()
+
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT, NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ verifySuspend(exactly(2)) { ensureRemoteAdminSession(1234) }
+ }
+
+ @Test
+ fun `openRemoteAdmin surfaces local node loss and releases the in-flight guard`() = runTest(testDispatcher) {
+ everySuspend { ensureRemoteAdminSession(1234) } throws LocalNodeUnavailableException("Remote admin")
+
+ viewModel.openRemoteAdmin(1234)
+ runCurrent()
+ viewModel.openRemoteAdmin(1234)
+ runCurrent()
+
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT, NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ verifySuspend(exactly(2)) { ensureRemoteAdminSession(1234) }
+ }
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
index 7172e65e52..36e07d6df3 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
@@ -17,18 +17,30 @@
package org.meshtastic.feature.node.detail
import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.answering.throws
+import dev.mokkery.every
+import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import org.meshtastic.core.repository.PlatformAnalytics
+import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
+import kotlin.test.BeforeTest
import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
@@ -37,16 +49,28 @@ class NodeManagementActionsTest {
private val nodeRepository = FakeNodeRepository()
private val radioController = FakeRadioController()
private val alertManager = mock<AlertManager>(MockMode.autofill)
+ private val snackbarManager = RecordingSnackbarManager()
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
+ private val resolveUiText = resolveNodeRequestFailureUiText
+ private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
- private val actions =
- NodeManagementActions(
- nodeRepository = nodeRepository,
- radioController = radioController,
- alertManager = alertManager,
- analytics = mock(MockMode.autofill),
- )
+ private val actions = actionsWith(radioController, alertManager)
+
+ @BeforeTest
+ fun setUp() {
+ nodeRepository.setNodes(emptyList())
+ snackbarManager.messages.clear()
+ }
+
+ private fun actionsWith(radio: RadioController, alerts: AlertManager) = NodeManagementActions(
+ nodeRepository = nodeRepository,
+ radioController = radio,
+ alertManager = alerts,
+ analytics = analytics,
+ snackbarManager = snackbarManager,
+ resolveUiText = resolveUiText,
+ )
@Test
fun requestRemoveNode_shows_confirmation_alert() {
@@ -72,19 +96,68 @@ class NodeManagementActionsTest {
@Test
fun requestRemoveNode_invokes_onAfterRemove_when_user_confirms() {
val realAlertManager = AlertManager()
- val actionsWithRealAlert =
- NodeManagementActions(
- nodeRepository = nodeRepository,
- radioController = radioController,
- alertManager = realAlertManager,
- analytics = mock(MockMode.autofill),
- )
+ val actionsWithRealAlert = actionsWith(radioController, realAlertManager)
val node = Node(num = 123, user = User(long_name = "Test Node"))
var afterRemoveCalled = false
actionsWithRealAlert.requestRemoveNode(testScope, node) { afterRemoveCalled = true }
realAlertManager.currentAlert.value?.onConfirm?.invoke()
+ testScope.runCurrent()
assertTrue(afterRemoveCalled)
}
+
+ @Test
+ fun requestRemoveNode_success_callback_failure_is_not_reported_as_radio_rejection() {
+ val realAlertManager = AlertManager()
+ val actionsWithRealAlert = actionsWith(radioController, realAlertManager)
+ val node = Node(num = 123, user = User(long_name = "Test Node"))
+ nodeRepository.setNodes(listOf(node))
+
+ actionsWithRealAlert.requestRemoveNode(testScope, node) { throw PacketQueueRejectedException("callback") }
+ realAlertManager.currentAlert.value?.onConfirm?.invoke()
+ testScope.runCurrent()
+
+ assertTrue(snackbarManager.messages.isEmpty())
+ }
+
+ @Test
+ fun requestRemoveNode_queue_rejection_keeps_success_callback_pending_and_surfaces_feedback() {
+ val rejectedRadio = mock<RadioController>()
+ val realAlertManager = AlertManager()
+ val rejectedActions = actionsWith(rejectedRadio, realAlertManager)
+ val node = Node(num = 123, user = User(long_name = "Test Node"))
+ var afterRemoveCalled = false
+ nodeRepository.setNodes(listOf(node))
+ every { rejectedRadio.generatePacketId() } returns 7
+ everySuspend { rejectedRadio.removeByNodenum(7, 123) } throws PacketQueueRejectedException("Remove node")
+
+ rejectedActions.requestRemoveNode(testScope, node) { afterRemoveCalled = true }
+ realAlertManager.currentAlert.value?.onConfirm?.invoke()
+ testScope.runCurrent()
+
+ assertFalse(afterRemoveCalled)
+ assertEquals(node, nodeRepository.nodeDBbyNum.value[123])
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ }
+
+ @Test
+ fun requestRemoveNode_local_node_loss_keeps_success_callback_pending_and_surfaces_feedback() {
+ val unavailableRadio = mock<RadioController>()
+ val realAlertManager = AlertManager()
+ val unavailableActions = actionsWith(unavailableRadio, realAlertManager)
+ val node = Node(num = 123, user = User(long_name = "Test Node"))
+ var afterRemoveCalled = false
+ nodeRepository.setNodes(listOf(node))
+ every { unavailableRadio.generatePacketId() } returns 7
+ everySuspend { unavailableRadio.removeByNodenum(7, 123) } throws LocalNodeUnavailableException("Remove node")
+
+ unavailableActions.requestRemoveNode(testScope, node) { afterRemoveCalled = true }
+ realAlertManager.currentAlert.value?.onConfirm?.invoke()
+ testScope.runCurrent()
+
+ assertFalse(afterRemoveCalled)
+ assertEquals(node, nodeRepository.nodeDBbyNum.value[123])
+ assertEquals(listOf(NODE_REQUEST_SEND_FAILED_TEXT), snackbarManager.messages)
+ }
}
diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/RecordingSnackbarManager.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/RecordingSnackbarManager.kt
new file mode 100644
index 0000000000..76c6556306
--- /dev/null
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/RecordingSnackbarManager.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.detail
+
+import androidx.compose.material3.SnackbarDuration
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.connect_radio_for_remote_admin
+import org.meshtastic.core.resources.node_request_send_failed
+import org.meshtastic.core.resources.remote_admin_unreachable
+import org.meshtastic.core.ui.util.SnackbarManager
+
+internal class RecordingSnackbarManager : SnackbarManager() {
+ val messages = mutableListOf<String>()
+
+ override fun showSnackbar(
+ message: String,
+ actionLabel: String?,
+ withDismissAction: Boolean,
+ duration: SnackbarDuration,
+ onAction: (() -> Unit)?,
+ ) {
+ messages += message
+ }
+}
+
+internal const val NODE_REQUEST_SEND_FAILED_TEXT = "Couldn't send request. Try again."
+
+internal val resolveNodeRequestFailureUiText: suspend (UiText) -> String = { text ->
+ when (text) {
+ is UiText.DynamicString -> text.value
+
+ is UiText.Resource ->
+ if (text.res == Res.string.node_request_send_failed) {
+ NODE_REQUEST_SEND_FAILED_TEXT
+ } else {
+ error("Unexpected UiText resource in test: ${text.res}")
+ }
+ }
+}
+
+internal val resolveNodeDetailUiTextForTest: suspend (UiText) -> String = { text ->
+ when (text) {
+ is UiText.DynamicString -> text.value
+
+ is UiText.Resource ->
+ when (text.res) {
+ Res.string.connect_radio_for_remote_admin -> "Connect to a radio to administer remote nodes."
+ Res.string.remote_admin_unreachable -> "Could not reach node — try again or move closer."
+ Res.string.node_request_send_failed -> NODE_REQUEST_SEND_FAILED_TEXT
+ else -> error("Unexpected UiText resource in test: ${text.res}")
+ }
+ }
+}
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 619a4a788a..23d9f73631 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -40,6 +40,7 @@ kotlin {
implementation(projects.core.di)
implementation(projects.core.takserver)
+ implementation(libs.kotlinx.atomicfu)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.coil)
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
index e84a75ba0d..d7773c2a9d 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ConfigRoute.kt
@@ -49,9 +49,11 @@ enum class ConfigRoute(
val route: Route,
val icon: DrawableResource? = null,
val type: Int = 0,
+ // Keep in sync with routes that issue multiple get requests; only single-response reads retain late responses.
+ val hasReadFanOut: Boolean = false,
) {
USER(Res.string.user, SettingsRoute.User, Res.drawable.ic_person, 0),
- CHANNELS(Res.string.channels, SettingsRoute.ChannelConfig, Res.drawable.ic_list, 0),
+ CHANNELS(Res.string.channels, SettingsRoute.ChannelConfig, Res.drawable.ic_list, 0, hasReadFanOut = true),
DEVICE(
Res.string.device,
SettingsRoute.Device,
@@ -70,6 +72,7 @@ enum class ConfigRoute(
SettingsRoute.Network,
Res.drawable.ic_wifi,
AdminMessage.ConfigType.NETWORK_CONFIG.value,
+ hasReadFanOut = true,
),
DISPLAY(
Res.string.display,
@@ -77,7 +80,13 @@ enum class ConfigRoute(
Res.drawable.ic_display_settings,
AdminMessage.ConfigType.DISPLAY_CONFIG.value,
),
- LORA(Res.string.lora, SettingsRoute.LoRa, Res.drawable.ic_cell_tower, AdminMessage.ConfigType.LORA_CONFIG.value),
+ LORA(
+ Res.string.lora,
+ SettingsRoute.LoRa,
+ Res.drawable.ic_cell_tower,
+ AdminMessage.ConfigType.LORA_CONFIG.value,
+ hasReadFanOut = true,
+ ),
BLUETOOTH(
Res.string.bluetooth,
SettingsRoute.Bluetooth,
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
index fcd518f006..993fe0b04f 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt
@@ -65,6 +65,7 @@ enum class ModuleRoute(
// False when the firmware has no ModuleConfigType to request this module per-request; the editor then relies on the
// connect-time config sync instead of a get (MeshBeacon: MeshBeaconConfig is in ModuleConfig but not in the enum).
val refreshable: Boolean = true,
+ val hasReadFanOut: Boolean = false,
) {
MQTT(Res.string.mqtt, SettingsRoute.MQTT, Res.drawable.ic_cloud, AdminMessage.ModuleConfigType.MQTT_CONFIG.value),
SERIAL(
@@ -78,6 +79,7 @@ enum class ModuleRoute(
SettingsRoute.ExtNotification,
Res.drawable.ic_notifications,
AdminMessage.ModuleConfigType.EXTNOTIF_CONFIG.value,
+ hasReadFanOut = true,
),
STORE_FORWARD(
Res.string.store_forward,
@@ -102,6 +104,7 @@ enum class ModuleRoute(
SettingsRoute.CannedMessage,
Res.drawable.ic_message,
AdminMessage.ModuleConfigType.CANNEDMSG_CONFIG.value,
+ hasReadFanOut = true,
),
AUDIO(
Res.string.audio,
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 4a74c5ac61..053f371b35 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -19,10 +19,14 @@ package org.meshtastic.feature.settings.radio
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.locks.SynchronizedObject
+import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -107,11 +111,14 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
internal val MANUAL_CHANNEL_WRITE_DELAY: Duration = 1.seconds
+private val REMOTE_READ_LATE_RESPONSE_GRACE: Duration = 2.minutes
/** Data class that represents the current RadioConfig state. */
data class RadioConfigState(
@@ -236,6 +243,8 @@ open class RadioConfigViewModel(
private var probeJob: Job? = null
private val channelUpdateMutex = Mutex()
+ private val manualChannelBatchJobsLock = SynchronizedObject()
+ private val manualChannelBatchJobs = mutableSetOf<Job>()
private var manualChannelBatchEnqueueing = false
private val manualChannelBatchRequestIds = mutableSetOf<Int>()
@@ -269,7 +278,16 @@ open class RadioConfigViewModel(
get() = _destNode
private val requestIds = MutableStateFlow(hashSetOf<Int>())
+
+ // Main-dispatcher confined with the other ViewModel request state below. Keep every access on viewModelScope unless
+ // these collections are moved behind explicit synchronization.
private val requestTimeoutJobs = mutableMapOf<Int, Job>()
+
+ // Only getter registrations enter this map; writes and destructive actions therefore cannot inherit late-read
+ // recovery merely because the user saved from a screen whose route name is still selected.
+ private val readRequestRoutes = mutableMapOf<Int, String>()
+ private val deferredRemoteReadErrors = mutableMapOf<Int, UiText>()
+ private val lateRemoteReads = mutableMapOf<Int, LateRemoteRead>()
private val _radioConfigState = MutableStateFlow(RadioConfigState())
val radioConfigState: StateFlow<RadioConfigState> = _radioConfigState
@@ -434,7 +452,7 @@ open class RadioConfigViewModel(
radioConfigUseCase.setHamMode(
destNum,
HamParameters(call_sign = user.long_name, short_name = user.short_name),
- onRequestId = ::registerRequestId,
+ onRequestId = ::registerWriteRequestId,
)
}
}
@@ -447,7 +465,7 @@ open class RadioConfigViewModel(
val destNum = destNum ?: destNode.value?.num ?: return
safeLaunch(tag = "setOwner") {
_radioConfigState.update { it.copy(userConfig = user) }
- radioConfigUseCase.setOwner(destNum, user, onRequestId = ::registerRequestId)
+ radioConfigUseCase.setOwner(destNum, user, onRequestId = ::registerWriteRequestId)
}
}
@@ -456,46 +474,55 @@ open class RadioConfigViewModel(
val destNum = destNum ?: destNode.value?.num ?: return
safeLaunch(tag = "setRemoteChannels") {
- // Manual channel saves are an ordered batch: only update canonical local state after every write request is
- // enqueued. Serialize batches so two ordered write plans cannot interleave on the radio link, and diff
- // each queued save against the canonical list at the moment it starts.
- channelUpdateMutex.withLock {
- val current = radioConfigState.value.channelList.ifEmpty { old }
- val updatePlan = getManualChannelUpdatePlan(new, current)
- if (updatePlan.isEmpty()) return@withLock
- if (!beginManualChannelBatch(updatePlan.size)) return@withLock
- val batchRequestIds = mutableSetOf<Int>()
-
- try {
- applyManualChannelUpdatePlan(
- updatePlan = updatePlan,
- currentSettings = current,
- finalSettings = new,
- writeChannel = { channel, onRequestId ->
- radioConfigUseCase.setRemoteChannel(destNum, channel, onRequestId)
- },
- registerRequestId = { packetId ->
- batchRequestIds.add(packetId)
- registerManualChannelBatchRequestId(packetId)
- },
- onInterrupted = { result ->
- reconcileInterruptedManualChannelUpdate(
- destNum = destNum,
- oldSettings = current,
- appliedSettings = result.appliedSettings,
- )
- },
- )
- commitManualChannelSettings(destNum = destNum, oldSettings = current, newSettings = new)
- finishManualChannelBatch()
- } catch (e: CancellationException) {
- abortManualChannelBatch(batchRequestIds)
- throw e
- } catch (e: Throwable) {
- abortManualChannelBatch(batchRequestIds)
- Logger.w(e) { "Manual channel update failed after enqueue" }
- e.message?.let(::sendError) ?: sendError(Res.string.unknown_error)
+ val batchJob = checkNotNull(currentCoroutineContext()[Job])
+ synchronized(manualChannelBatchJobsLock) { manualChannelBatchJobs += batchJob }
+ try {
+ // Manual channel saves are an ordered batch: only update canonical local state after every write
+ // request is enqueued. Serialize batches so two ordered write plans cannot interleave on the radio
+ // link, and diff each queued save against the canonical list at the moment it starts.
+ channelUpdateMutex.withLock {
+ val current = radioConfigState.value.channelList.ifEmpty { old }
+ val updatePlan = getManualChannelUpdatePlan(new, current)
+ if (updatePlan.isEmpty()) return@withLock
+ if (!beginManualChannelBatch(updatePlan.size)) return@withLock
+ val batchRequestIds = mutableSetOf<Int>()
+
+ try {
+ applyManualChannelUpdatePlan(
+ updatePlan = updatePlan,
+ currentSettings = current,
+ finalSettings = new,
+ writeChannel = { channel, onRequestId ->
+ currentCoroutineContext().ensureActive()
+ radioConfigUseCase.setRemoteChannel(destNum, channel, onRequestId)
+ },
+ registerRequestId = { packetId ->
+ batchRequestIds.add(packetId)
+ registerManualChannelBatchRequestId(packetId)
+ },
+ onInterrupted = { result ->
+ reconcileInterruptedManualChannelUpdate(
+ destNum = destNum,
+ oldSettings = current,
+ appliedSettings = result.appliedSettings,
+ )
+ },
+ )
+ currentCoroutineContext().ensureActive()
+ commitManualChannelSettings(destNum = destNum, oldSettings = current, newSettings = new)
+ finishManualChannelBatch()
+ } catch (e: CancellationException) {
+ abortManualChannelBatch(batchRequestIds)
+ throw e
+ } catch (e: Throwable) {
+ abortManualChannelBatch(batchRequestIds)
+ if (e !is Exception) throw e
+ Logger.w(e) { "Manual channel update failed after enqueue" }
+ e.message?.let(::sendError) ?: sendError(Res.string.unknown_error)
+ }
}
+ } finally {
+ synchronized(manualChannelBatchJobsLock) { manualChannelBatchJobs -= batchJob }
}
}
}
@@ -553,7 +580,7 @@ open class RadioConfigViewModel(
)
}
expectRestartIfLocal(config.saveRebootBehavior())
- radioConfigUseCase.setConfig(destNum, config, onRequestId = ::registerRequestId)
+ radioConfigUseCase.setConfig(destNum, config, onRequestId = ::registerWriteRequestId)
}
}
@@ -585,18 +612,20 @@ open class RadioConfigViewModel(
)
}
expectRestartIfLocal(config.saveRebootBehavior())
- radioConfigUseCase.setModuleConfig(destNum, config, onRequestId = ::registerRequestId)
+ radioConfigUseCase.setModuleConfig(destNum, config, onRequestId = ::registerWriteRequestId)
}
}
fun setRingtone(ringtone: String) {
val destNum = destNum ?: destNode.value?.num ?: return
+ retireReadRequestsForRoute(radioConfigState.value.route)
_radioConfigState.update { it.copy(ringtone = ringtone) }
safeLaunch(tag = "setRingtone") { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
fun setCannedMessages(messages: String) {
val destNum = destNum ?: destNode.value?.num ?: return
+ retireReadRequestsForRoute(radioConfigState.value.route)
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
safeLaunch(tag = "setCannedMessages") { radioConfigUseCase.setCannedMessages(destNum, messages) }
}
@@ -606,18 +635,24 @@ open class RadioConfigViewModel(
val isLocal = radioConfigState.value.isLocal
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
- analytics.trackAction("admin_action", mapOf("route" to route.lowercase(), "is_remote" to !isLocal))
+ val trackAdminAction = {
+ analytics.trackAction("admin_action", mapOf("route" to route.lowercase(), "is_remote" to !isLocal))
+ }
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {
AdminRoute.SET_TIME.name ->
- safeLaunch(tag = "setTime") { adminActionsUseCase.setTime(destNum, onRequestId = ::registerRequestId) }
+ safeLaunch(tag = "setTime") {
+ adminActionsUseCase.setTime(destNum, onRequestId = ::registerRequestId)
+ trackAdminAction()
+ }
AdminRoute.REBOOT.name ->
safeLaunch(tag = "reboot") {
expectRestartIfLocal(RebootBehavior.ALWAYS)
adminActionsUseCase.reboot(destNum, onRequestId = ::registerRequestId)
+ trackAdminAction()
}
AdminRoute.SHUTDOWN.name ->
@@ -627,6 +662,7 @@ open class RadioConfigViewModel(
} else {
safeLaunch(tag = "shutdown") {
adminActionsUseCase.shutdown(destNum, onRequestId = ::registerRequestId)
+ trackAdminAction()
}
}
}
@@ -636,6 +672,7 @@ open class RadioConfigViewModel(
val isLocal = (destNum == myNodeNum)
if (isLocal) nodeRestartTracker.expectRestart()
adminActionsUseCase.factoryReset(destNum, isLocal, onRequestId = ::registerRequestId)
+ trackAdminAction()
}
AdminRoute.NODEDB_RESET.name ->
@@ -645,6 +682,7 @@ open class RadioConfigViewModel(
// factory reset — mark it expected so the UI shows "restarting" instead of a surprise disconnect.
if (isLocal) nodeRestartTracker.expectRestart()
adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal, ::registerRequestId)
+ trackAdminAction()
}
}
}
@@ -797,17 +835,19 @@ open class RadioConfigViewModel(
when (route) {
ConfigRoute.USER ->
- safeLaunch(tag = "getOwner") { radioConfigUseCase.getOwner(destNum, onRequestId = ::registerRequestId) }
+ safeLaunch(tag = "getOwner") {
+ radioConfigUseCase.getOwner(destNum, onRequestId = ::registerReadRequestId)
+ }
ConfigRoute.CHANNELS -> {
safeLaunch(tag = "getChannel0") {
- radioConfigUseCase.getChannel(destNum, 0, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getChannel(destNum, 0, onRequestId = ::registerReadRequestId)
}
safeLaunch(tag = "getLoraConfig") {
radioConfigUseCase.getConfig(
destNum,
AdminMessage.ConfigType.LORA_CONFIG.value,
- onRequestId = ::registerRequestId,
+ onRequestId = ::registerReadRequestId,
)
}
// channel editor is synchronous, so we don't use requestIds as total
@@ -819,7 +859,7 @@ open class RadioConfigViewModel(
radioConfigUseCase.getConfig(
destNum,
AdminMessage.ConfigType.SESSIONKEY_CONFIG.value,
- onRequestId = ::registerRequestId,
+ onRequestId = ::registerReadRequestId,
)
}
setResponseStateTotal(2)
@@ -834,32 +874,32 @@ open class RadioConfigViewModel(
private fun loadConfigRoute(destNum: Int, route: ConfigRoute) {
if (route == ConfigRoute.LORA) {
safeLaunch(tag = "getChannel0ForLora") {
- radioConfigUseCase.getChannel(destNum, 0, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getChannel(destNum, 0, onRequestId = ::registerReadRequestId)
}
}
if (route == ConfigRoute.NETWORK) {
safeLaunch(tag = "getConnectionStatus") {
- radioConfigUseCase.getDeviceConnectionStatus(destNum, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getDeviceConnectionStatus(destNum, onRequestId = ::registerReadRequestId)
}
}
safeLaunch(tag = "getConfig") {
- radioConfigUseCase.getConfig(destNum, route.type, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getConfig(destNum, route.type, onRequestId = ::registerReadRequestId)
}
}
private fun loadModuleRoute(destNum: Int, route: ModuleRoute) {
if (route == ModuleRoute.CANNED_MESSAGE) {
safeLaunch(tag = "getCannedMessages") {
- radioConfigUseCase.getCannedMessages(destNum, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getCannedMessages(destNum, onRequestId = ::registerReadRequestId)
}
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
safeLaunch(tag = "getRingtone") {
- radioConfigUseCase.getRingtone(destNum, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getRingtone(destNum, onRequestId = ::registerReadRequestId)
}
}
safeLaunch(tag = "getModuleConfig") {
- radioConfigUseCase.getModuleConfig(destNum, route.type, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getModuleConfig(destNum, route.type, onRequestId = ::registerReadRequestId)
}
}
@@ -903,6 +943,12 @@ open class RadioConfigViewModel(
removeRequestIds(batchRequestIds)
}
+ private fun invalidateManualChannelBatch() {
+ manualChannelBatchEnqueueing = false
+ val jobs = synchronized(manualChannelBatchJobsLock) { manualChannelBatchJobs.toList() }
+ jobs.forEach { it.cancel() }
+ }
+
/**
* True while a manual channel batch is in flight — from enqueue through the ack-wait that outlives
* [finishManualChannelBatch]. [manualChannelBatchEnqueueing] alone only covers the enqueue phase, so pending batch
@@ -972,6 +1018,9 @@ open class RadioConfigViewModel(
private fun registerRequestId(packetId: Int) {
requestTimeoutJobs.remove(packetId)?.cancel()
+ removeLateRemoteRead(packetId)
+ readRequestRoutes.remove(packetId)
+ deferredRemoteReadErrors.remove(packetId)
requestIds.update { it.withPacketId(packetId) }
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
@@ -992,8 +1041,13 @@ open class RadioConfigViewModel(
if (requestIds.value.contains(packetId)) {
// Capture batch membership before removeRequestId drops the last id and empties the batch set.
val timedOutBatchRequest = packetId in manualChannelBatchRequestIds
+ val requestRoute = readRequestRoutes[packetId].orEmpty()
+ val deferredRemoteReadError = deferredRemoteReadErrors[packetId]
removeRequestId(packetId)
- if (requestIds.value.isEmpty()) {
+ if (isSingleResponseRemoteReadRoute(requestRoute)) {
+ retainLateRemoteRead(packetId, requestRoute)
+ }
+ if (!hasPendingRequestsForRoute(requestRoute) && radioConfigState.value.route == requestRoute) {
// A save that reboots the node races the reboot against its ACK; a timeout here during an
// expected restart means the reboot won — treat it as the restarting-success, not an error.
// A manual channel batch never reboots, so exclude it even inside a stale restart window.
@@ -1005,13 +1059,27 @@ open class RadioConfigViewModel(
) {
setResponseStateSuccess()
} else {
- sendError(Res.string.timeout)
+ deferredRemoteReadError?.let(::sendError) ?: sendError(Res.string.timeout)
}
}
}
}
}
+ private fun registerReadRequestId(packetId: Int) {
+ val route = radioConfigState.value.route
+ registerRequestId(packetId)
+ readRequestRoutes[packetId] = route
+ }
+
+ private fun registerWriteRequestId(packetId: Int) {
+ val writtenRoute = radioConfigState.value.route
+ retireReadRequestsForRoute(writtenRoute)
+ registerRequestId(packetId)
+ // A write owns the visible request flow even if a timed-out read retry is still tracked in the background.
+ _radioConfigState.update { it.copy(route = "", responseState = ResponseState.Loading()) }
+ }
+
private fun registerManualChannelBatchRequestId(packetId: Int) {
manualChannelBatchRequestIds.add(packetId)
registerRequestId(packetId)
@@ -1019,6 +1087,12 @@ open class RadioConfigViewModel(
private fun hasUnrelatedPendingRequest(): Boolean = requestIds.value.any { it !in manualChannelBatchRequestIds }
+ private fun hasPendingRequestsForRoute(route: String): Boolean = if (route.isEmpty()) {
+ requestIds.value.any { it !in readRequestRoutes }
+ } else {
+ readRequestRoutes.any { (packetId, requestRoute) -> packetId in requestIds.value && requestRoute == route }
+ }
+
private fun hasPendingRequestRegistration(): Boolean = requestIds.value.isEmpty() &&
manualChannelBatchRequestIds.isEmpty() &&
radioConfigState.value.responseState is ResponseState.Loading
@@ -1026,29 +1100,101 @@ open class RadioConfigViewModel(
private fun clearRequestIds() {
requestTimeoutJobs.values.forEach { it.cancel() }
requestTimeoutJobs.clear()
+ readRequestRoutes.clear()
+ deferredRemoteReadErrors.clear()
+ lateRemoteReads.values.forEach { it.expiryJob.cancel() }
+ lateRemoteReads.clear()
manualChannelBatchRequestIds.clear()
requestIds.value = hashSetOf()
}
private fun removeRequestId(packetId: Int) {
requestTimeoutJobs.remove(packetId)?.cancel()
+ readRequestRoutes.remove(packetId)
+ deferredRemoteReadErrors.remove(packetId)
manualChannelBatchRequestIds.remove(packetId)
requestIds.update { it.withoutPacketId(packetId) }
}
private fun removeRequestIds(packetIds: Set<Int>) {
packetIds.forEach { requestTimeoutJobs.remove(it)?.cancel() }
+ packetIds.forEach {
+ readRequestRoutes.remove(it)
+ deferredRemoteReadErrors.remove(it)
+ removeLateRemoteRead(it)
+ }
manualChannelBatchRequestIds.removeAll(packetIds)
requestIds.update { ids -> ids.withoutPacketIds(packetIds) }
}
+ private fun retireReadRequestsForRoute(route: String) {
+ if (route.isEmpty()) return
+ val redundantReadIds = readRequestRoutes.filterValues { it == route }.keys.toSet()
+ val retainedReadIds = lateRemoteReads.filterValues { it.route == route }.keys.toList()
+ removeRequestIds(redundantReadIds)
+ retainedReadIds.forEach(::removeLateRemoteRead)
+ if (requestIds.value.isEmpty()) {
+ _radioConfigState.update { state ->
+ val resolvedLateRead =
+ state.responseState is ResponseState.Loading || state.responseState is ResponseState.Error
+ if (state.route == route && resolvedLateRead) {
+ state.copy(responseState = ResponseState.Empty)
+ } else {
+ state
+ }
+ }
+ }
+ }
+
private fun processPacketResponse(packet: MeshPacket) {
val destNum = destNum ?: destNode.value?.num ?: return
- val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
+ val requestId = packet.decoded?.request_id
+ val lateRemoteReadRoute = requestId?.let { lateRemoteReads[it]?.route }
+ val pendingRequestIds = requestIds.value + lateRemoteReads.keys
+ val result = processRadioResponseUseCase(packet, destNum, pendingRequestIds) ?: return
val route = radioConfigState.value.route
+ val isLateRemoteRead = requestId != null && lateRemoteReadRoute != null
+
+ if (isLateRemoteRead) {
+ when (result) {
+ is RadioResponseResult.Error -> {
+ if (result.routingError != Routing.Error.MAX_RETRANSMIT) {
+ removeLateRemoteRead(checkNotNull(requestId))
+ }
+ return
+ }
+
+ // A routing ACK confirms delivery but does not contain the requested settings. Keep the retained read
+ // isolated from any newer save and continue waiting for its ADMIN_APP response.
+ is RadioResponseResult.Success -> return
+
+ else -> Unit
+ }
+ }
when (result) {
is RadioResponseResult.Error -> {
+ if (
+ requestId != null &&
+ result.routingError == Routing.Error.MAX_RETRANSMIT &&
+ isRemoteReadRoute(readRequestRoutes[requestId].orEmpty())
+ ) {
+ // A remote reply can arrive after the connected radio exhausts reliable-send ACK tracking. Keep
+ // this read alive until its existing UX deadline; a matching ADMIN_APP response can still satisfy
+ // it, while the deferred routing error remains the most specific failure if no response arrives.
+ deferredRemoteReadErrors[requestId] = result.message
+ return
+ }
+ val responseReadRoute = requestId?.let(readRequestRoutes::get)
+ if (responseReadRoute != null && responseReadRoute != route) {
+ removeRequestId(checkNotNull(requestId))
+ return
+ }
+ // A routing/admin error is terminal for the current request flow. Drop every outstanding request ID
+ // and cancel its timeout so a late timeout cannot overwrite the specific failure or block the next
+ // retry.
+ invalidateManualChannelBatch()
+ clearRequestIds()
sendError(result.message)
// Abort the AdminRoute flow — do not fire the destructive action
// (reboot/shutdown/factory_reset) if the metadata preflight failed.
@@ -1056,10 +1202,9 @@ open class RadioConfigViewModel(
}
is RadioResponseResult.Success -> {
- if (route.isEmpty()) {
- val data = packet.decoded!!
- removeRequestId(data.request_id)
- if (requestIds.value.isEmpty()) {
+ if (requestId != null && !isLateRemoteRead && route.isEmpty() && requestId !in readRequestRoutes) {
+ removeRequestId(requestId)
+ if (!hasPendingRequestsForRoute(route)) {
completeSetRequestOrProgressBatch()
} else {
incrementCompleted()
@@ -1069,7 +1214,7 @@ open class RadioConfigViewModel(
is RadioResponseResult.Metadata -> {
_radioConfigState.update { it.copy(metadata = result.metadata) }
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.ChannelResponse -> {
@@ -1088,15 +1233,15 @@ open class RadioConfigViewModel(
},
)
}
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
val index = response.index
- if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
+ if (!isLateRemoteRead && index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
safeLaunch(tag = "getNextChannel") {
- radioConfigUseCase.getChannel(destNum, index + 1, onRequestId = ::registerRequestId)
+ radioConfigUseCase.getChannel(destNum, index + 1, onRequestId = ::registerReadRequestId)
}
}
- } else {
+ } else if (!isLateRemoteRead) {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
}
@@ -1104,7 +1249,7 @@ open class RadioConfigViewModel(
is RadioResponseResult.Owner -> {
_radioConfigState.update { it.copy(userConfig = result.user) }
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.ConfigResponse -> {
@@ -1124,7 +1269,7 @@ open class RadioConfigViewModel(
),
)
}
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.ModuleConfigResponse -> {
@@ -1152,25 +1297,33 @@ open class RadioConfigViewModel(
),
)
}
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.CannedMessages -> {
_radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.Ringtone -> {
_radioConfigState.update { it.copy(ringtone = result.ringtone) }
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
is RadioResponseResult.ConnectionStatus -> {
_radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
- incrementCompleted()
+ if (!isLateRemoteRead) incrementCompleted()
}
}
+ if (isLateRemoteRead) {
+ removeLateRemoteRead(checkNotNull(requestId))
+ // A late response satisfies its single-response route, including any retry of that route. Retire only
+ // redundant reads with the same route; a concurrently registered save owns no read route and is retained.
+ retireReadRequestsForRoute(checkNotNull(lateRemoteReadRoute))
+ return
+ }
+
// Routing ACKs (Success) share the same request_id as the upcoming ADMIN_APP response.
// Removing the id here would cause the actual admin response to be silently dropped,
// because processRadioResponseUseCase checks `request_id in requestIds`.
@@ -1181,7 +1334,7 @@ open class RadioConfigViewModel(
sendAdminRequest(destNum)
}
- val requestId = packet.decoded?.request_id ?: return
+ if (requestId == null) return
// Defer the removal so a chain continuation launched above (e.g. the next getChannel of a
// sequential channel fetch) registers its request id first — launches run FIFO on the main
// dispatcher, and registration is the continuation's first act before its send. Removing inline
@@ -1199,8 +1352,37 @@ open class RadioConfigViewModel(
}
}
}
+
+ private fun isRemoteReadRoute(route: String): Boolean = destNum != null &&
+ destNum != myNodeNum &&
+ (ConfigRoute.entries.any { it.name == route } || ModuleRoute.entries.any { it.name == route })
+
+ private fun isSingleResponseRemoteReadRoute(route: String): Boolean {
+ if (!isRemoteReadRoute(route)) return false
+ val hasReadFanOut =
+ ConfigRoute.entries.firstOrNull { it.name == route }?.hasReadFanOut
+ ?: ModuleRoute.entries.firstOrNull { it.name == route }?.hasReadFanOut
+ // Unknown routes are never retained.
+ return hasReadFanOut == false
+ }
+
+ private fun retainLateRemoteRead(packetId: Int, route: String) {
+ removeLateRemoteRead(packetId)
+ val expiryJob =
+ safeLaunch(tag = "expireLateRemoteRead") {
+ delay(REMOTE_READ_LATE_RESPONSE_GRACE)
+ lateRemoteReads.remove(packetId)
+ }
+ lateRemoteReads[packetId] = LateRemoteRead(route, expiryJob)
+ }
+
+ private fun removeLateRemoteRead(packetId: Int) {
+ lateRemoteReads.remove(packetId)?.expiryJob?.cancel()
+ }
}
+private data class LateRemoteRead(val route: String, val expiryJob: Job)
+
internal data class ManualChannelUpdateResult(val packetIds: List<Int>, val finalSettings: List<ChannelSettings>)
internal data class InterruptedManualChannelUpdate(
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
index 7dc0f232af..4af73ef6dd 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -37,12 +37,12 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
-import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
@@ -76,6 +76,7 @@ import org.meshtastic.core.testing.FakeLockdownCoordinator
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.settings.navigation.ConfigRoute
+import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.component.loRaBandwidthSelection
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
@@ -90,6 +91,7 @@ import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
@@ -105,6 +107,18 @@ import kotlin.time.Duration
@OptIn(ExperimentalCoroutinesApi::class)
class RadioConfigViewModelTest {
+ @Test
+ fun `route read fan-out flags match multi-request loaders`() {
+ assertEquals(
+ setOf(ConfigRoute.CHANNELS, ConfigRoute.LORA, ConfigRoute.NETWORK),
+ ConfigRoute.entries.filter(ConfigRoute::hasReadFanOut).toSet(),
+ )
+ assertEquals(
+ setOf(ModuleRoute.CANNED_MESSAGE, ModuleRoute.EXT_NOTIFICATION),
+ ModuleRoute.entries.filter(ModuleRoute::hasReadFanOut).toSet(),
+ )
+ }
+
private val testDispatcher = UnconfinedTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
@@ -177,33 +191,38 @@ class RadioConfigViewModelTest {
Dispatchers.resetMain()
}
- private fun createViewModel(destNum: Int? = null) = RadioConfigViewModel(
- destNum = destNum,
- radioConfigRepository = radioConfigRepository,
- packetRepository = packetRepository,
- serviceRepository = serviceRepository,
- nodeRepository = nodeRepository,
- locationRepository = locationRepository,
- mapConsentPrefs = mapConsentPrefs,
- analyticsPrefs = analyticsPrefs,
- homoglyphEncodingPrefs = homoglyphEncodingPrefs,
- importProfileUseCase = importProfileUseCase,
- exportProfileUseCase = exportProfileUseCase,
- importSecurityConfigUseCase = importSecurityConfigUseCase,
- securityKeyBackupStore = securityKeyBackupStore,
- snackbarManager = snackbarManager,
- nodeRestartTracker = nodeRestartTracker,
- installProfileUseCase = installProfileUseCase,
- radioConfigUseCase = radioConfigUseCase,
- adminActionsUseCase = adminActionsUseCase,
- processRadioResponseUseCase = processRadioResponseUseCase,
- locationService = locationService,
- fileService = fileService,
- mqttManager = mqttManager,
- lockdownCoordinator = FakeLockdownCoordinator(),
- analytics = mock(MockMode.autofill),
- )
- .also { createdViewModels += it }
+ /** Keeps assertions and ViewModel Main work on the same virtual-time scheduler. */
+ private fun runTest(block: suspend TestScope.() -> Unit) =
+ kotlinx.coroutines.test.runTest(testDispatcher, testBody = block)
+
+ private fun createViewModel(destNum: Int? = null, snackbarManager: SnackbarManager = this.snackbarManager) =
+ RadioConfigViewModel(
+ destNum = destNum,
+ radioConfigRepository = radioConfigRepository,
+ packetRepository = packetRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ locationRepository = locationRepository,
+ mapConsentPrefs = mapConsentPrefs,
+ analyticsPrefs = analyticsPrefs,
+ homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ importProfileUseCase = importProfileUseCase,
+ exportProfileUseCase = exportProfileUseCase,
+ importSecurityConfigUseCase = importSecurityConfigUseCase,
+ securityKeyBackupStore = securityKeyBackupStore,
+ snackbarManager = snackbarManager,
+ nodeRestartTracker = nodeRestartTracker,
+ installProfileUseCase = installProfileUseCase,
+ radioConfigUseCase = radioConfigUseCase,
+ adminActionsUseCase = adminActionsUseCase,
+ processRadioResponseUseCase = processRadioResponseUseCase,
+ locationService = locationService,
+ fileService = fileService,
+ mqttManager = mqttManager,
+ lockdownCoordinator = FakeLockdownCoordinator(),
+ analytics = mock(MockMode.autofill),
+ )
+ .also { createdViewModels += it }
@Test
fun `setConfig calls useCase`() = runTest {
@@ -467,6 +486,77 @@ class RadioConfigViewModelTest {
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
}
+ @Test
+ fun `routing error stops remaining manual channel writes`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val old = listOf(ChannelSettings(name = "A"), ChannelSettings(name = "B"), ChannelSettings(name = "C"))
+ val new = listOf(old[0], old[2], old[1])
+ val writtenIndexes = mutableListOf<Int>()
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), 123, any()) } returns
+ RadioResponseResult.Error(org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+
+ everySuspend { radioConfigUseCase.setRemoteChannel(any(), any(), any()) } calls
+ {
+ val channel = it.args[1] as Channel
+ writtenIndexes += channel.index
+ it.args.onRequestIdArg()(41)
+ 41
+ }
+
+ viewModel.updateChannels(new, old)
+ runCurrent()
+ assertEquals(listOf(1), writtenIndexes)
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 41)))
+ runCurrent()
+ advanceTimeBy(MANUAL_CHANNEL_WRITE_DELAY.inWholeMilliseconds + 1)
+ runCurrent()
+
+ assertEquals(listOf(1), writtenIndexes, "a terminal response must invalidate the rest of the batch")
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
+ }
+
+ @Test
+ fun `routing error cancels manual channel batches queued behind the active batch`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val old = listOf(ChannelSettings(name = "A"), ChannelSettings(name = "B"), ChannelSettings(name = "C"))
+ val firstUpdate = listOf(old[0], old[2], old[1])
+ val secondUpdate = listOf(old[0], ChannelSettings(name = "D"), old[2])
+ val writtenIndexes = mutableListOf<Int>()
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), 123, any()) } returns
+ RadioResponseResult.Error(org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+
+ everySuspend { radioConfigUseCase.setRemoteChannel(any(), any(), any()) } calls
+ {
+ writtenIndexes += (it.args[1] as Channel).index
+ it.args.onRequestIdArg()(41)
+ delay(10_000)
+ 41
+ }
+
+ viewModel.updateChannels(firstUpdate, old)
+ runCurrent()
+ viewModel.updateChannels(secondUpdate, old)
+ runCurrent()
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 41)))
+ runCurrent()
+ advanceUntilIdle()
+
+ assertEquals(listOf(1), writtenIndexes, "terminal invalidation must cancel active and queued channel batches")
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
+ }
+
@Test
fun `updateChannels reconciles applied channel writes when ordered write fails`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
@@ -965,6 +1055,60 @@ class RadioConfigViewModelTest {
verifySuspend { radioConfigUseCase.setCannedMessages(123, "Hello|World") }
}
+ @Test
+ fun `setRingtone retires the pending route read without stranding loading`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ everySuspend { radioConfigUseCase.getRingtone(any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(41)
+ 41
+ }
+ everySuspend { radioConfigUseCase.getModuleConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+ everySuspend { radioConfigUseCase.setRingtone(any(), any()) } returns Unit
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.setResponseStateLoading(ModuleRoute.EXT_NOTIFICATION)
+ runCurrent()
+ viewModel.setRingtone("ringtone.mp3")
+ runCurrent()
+
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+ }
+
+ @Test
+ fun `setCannedMessages retires the pending route read without stranding loading`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ everySuspend { radioConfigUseCase.getCannedMessages(any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(43)
+ 43
+ }
+ everySuspend { radioConfigUseCase.getModuleConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(44)
+ 44
+ }
+ everySuspend { radioConfigUseCase.setCannedMessages(any(), any()) } returns Unit
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.setResponseStateLoading(ModuleRoute.CANNED_MESSAGE)
+ runCurrent()
+ viewModel.setCannedMessages("Hello|World")
+ runCurrent()
+
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+ }
+
@Test
fun `destNum from SavedStateHandle resolves destNode`() = runTest {
val node = Node(num = 456, user = User(id = "!456"))
@@ -1095,6 +1239,329 @@ class RadioConfigViewModelTest {
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
}
+ @Test
+ fun `routing error clears request timeout without replacing the specific failure`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getOwner(any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+
+ viewModel = createViewModel()
+ viewModel.setResponseStateLoading(ConfigRoute.USER)
+ runCurrent()
+ verifySuspend { radioConfigUseCase.getOwner(123, any()) }
+
+ val failure = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Error(failure)
+ packetFlow.emit(MeshPacket())
+ runCurrent()
+
+ assertEquals(ResponseState.Error(failure), viewModel.radioConfigState.value.responseState)
+
+ advanceTimeBy(31_000)
+ runCurrent()
+
+ assertEquals(ResponseState.Error(failure), viewModel.radioConfigState.value.responseState)
+ }
+
+ @Test
+ fun `remote read accepts response after max retransmit before request deadline`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ val config = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+ var response: RadioResponseResult = RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+ every { processRadioResponseUseCase(any(), 456, any()) } calls { response }
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
+
+ response = RadioResponseResult.ConfigResponse(config)
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertEquals(config.device, viewModel.radioConfigState.value.radioConfig.device)
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+
+ advanceTimeBy(31_000)
+ runCurrent()
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+ }
+
+ @Test
+ fun `remote read surfaces max retransmit at deadline and accepts later response`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ var nextRequestId = 42
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(nextRequestId)
+ nextRequestId
+ }
+ val config = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+ var response: RadioResponseResult = RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
+ every { processRadioResponseUseCase(any(), 456, any()) } calls
+ {
+ val pendingRequestIds = it.args[2] as Set<Int>
+ if (42 in pendingRequestIds) response else null
+ }
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
+
+ advanceTimeBy(30_001)
+ runCurrent()
+
+ assertEquals(ResponseState.Error(maxRetransmit), viewModel.radioConfigState.value.responseState)
+
+ advanceTimeBy(55_000)
+ nextRequestId = 43
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
+
+ response = RadioResponseResult.Success
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
+
+ response = RadioResponseResult.ConfigResponse(config)
+ advanceTimeBy(5_000)
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertEquals(config.device, viewModel.radioConfigState.value.radioConfig.device)
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+
+ advanceTimeBy(31_000)
+ runCurrent()
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+ }
+
+ @Test
+ fun `late remote read clears its deadline error without a retry`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ val config = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+ var response: RadioResponseResult = RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+ every { processRadioResponseUseCase(any(), 456, any()) } calls
+ {
+ @Suppress("UNCHECKED_CAST")
+ val pendingRequestIds = it.args[2] as Set<Int>
+ response.takeIf { 42 in pendingRequestIds }
+ }
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+ advanceTimeBy(30_001)
+ runCurrent()
+ assertEquals(ResponseState.Error(maxRetransmit), viewModel.radioConfigState.value.responseState)
+
+ response = RadioResponseResult.ConfigResponse(config)
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertEquals(config.device, viewModel.radioConfigState.value.radioConfig.device)
+ assertEquals(ResponseState.Empty, viewModel.radioConfigState.value.responseState)
+ }
+
+ @Test
+ fun `late remote read routing ack cannot complete a newer save`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val config = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+ everySuspend { radioConfigUseCase.setConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(43)
+ 43
+ }
+ every { processRadioResponseUseCase(any(), 456, any()) } calls
+ {
+ val requestId = (it.args[0] as MeshPacket).decoded?.request_id
+
+ @Suppress("UNCHECKED_CAST")
+ val pendingRequestIds = it.args[2] as Set<Int>
+ RadioResponseResult.Success.takeIf { requestId in pendingRequestIds }
+ }
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ advanceTimeBy(30_001)
+ runCurrent()
+
+ viewModel.setConfig(config)
+ runCurrent()
+ assertEquals(ResponseState.Loading(), viewModel.radioConfigState.value.responseState)
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertEquals(
+ ResponseState.Loading(),
+ viewModel.radioConfigState.value.responseState,
+ "the retained read ACK must not consume the active save request",
+ )
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 43)))
+ runCurrent()
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
+ }
+
+ @Test
+ fun `newer save supersedes a retained read and its retry`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ val staleConfig = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+ val savedConfig = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 300))
+ var nextReadId = 42
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.getConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(nextReadId)
+ nextReadId
+ }
+ everySuspend { radioConfigUseCase.setConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(44)
+ 44
+ }
+ every { processRadioResponseUseCase(any(), 456, any()) } calls
+ {
+ val packet = it.args[0] as MeshPacket
+
+ @Suppress("UNCHECKED_CAST")
+ val pendingRequestIds = it.args[2] as Set<Int>
+ when (packet.decoded?.request_id) {
+ 42 -> RadioResponseResult.ConfigResponse(staleConfig).takeIf { 42 in pendingRequestIds }
+ 44 -> RadioResponseResult.Success.takeIf { 44 in pendingRequestIds }
+ else -> RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
+ }
+ }
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ advanceTimeBy(30_001)
+ runCurrent()
+
+ nextReadId = 43
+ viewModel.loadConfigRoute(ConfigRoute.DEVICE)
+ runCurrent()
+ viewModel.setConfig(savedConfig)
+ runCurrent()
+ assertEquals(ResponseState.Loading(), viewModel.radioConfigState.value.responseState)
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+ assertEquals(
+ ResponseState.Loading(),
+ viewModel.radioConfigState.value.responseState,
+ "a superseded read must not clear the active save",
+ )
+ assertEquals(savedConfig.device, viewModel.radioConfigState.value.radioConfig.device)
+
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 44)))
+ runCurrent()
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
+
+ advanceTimeBy(31_000)
+ runCurrent()
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Success)
+ }
+
+ @Test
+ fun `remote write keeps max retransmit terminal`() = runTest {
+ val localNode = Node(num = 100, user = User(id = "!100"))
+ val remoteNode = Node(num = 456, user = User(id = "!456"))
+ val packetFlow = MutableSharedFlow<MeshPacket>()
+ val maxRetransmit = org.meshtastic.core.resources.UiText.DynamicString("Max Retransmission Reached")
+ val config = Config(device = Config.DeviceConfig(node_info_broadcast_secs = 900))
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ everySuspend { radioConfigUseCase.setConfig(any(), any(), any()) } calls
+ {
+ it.args.onRequestIdArg()(42)
+ 42
+ }
+ every { processRadioResponseUseCase(any(), 456, any()) } returns
+ RadioResponseResult.Error(maxRetransmit, Routing.Error.MAX_RETRANSMIT)
+ nodeRepository.setNodes(listOf(localNode, remoteNode))
+ nodeRepository.setMyNodeInfo(myNodeInfo(myNodeNum = 100))
+ viewModel = createViewModel(destNum = 456)
+
+ viewModel.setConfig(config)
+ runCurrent()
+ packetFlow.emit(MeshPacket(decoded = Data(request_id = 42)))
+ runCurrent()
+
+ assertEquals(ResponseState.Error(maxRetransmit), viewModel.radioConfigState.value.responseState)
+
+ advanceTimeBy(31_000)
+ runCurrent()
+ assertEquals(ResponseState.Error(maxRetransmit), viewModel.radioConfigState.value.responseState)
+ }
+
@Test
fun `Admin actions call correct useCases`() = runTest {
val node = Node(num = 123, user = User(id = "!123"))
@@ -1212,6 +1679,8 @@ class RadioConfigViewModelTest {
}
viewModel.setResponseStateLoading(ConfigRoute.USER)
+ runCurrent()
+ verifySuspend { radioConfigUseCase.getOwner(123, any()) }
// state should be loading
assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Loading)
@@ -1220,10 +1689,7 @@ class RadioConfigViewModelTest {
advanceTimeBy(31_000)
runCurrent()
- // after timeout, the request ID should be removed, and if empty, sendError is called.
- // It's hard to assert sendError directly without a mock on a channel, but we can verify it doesn't stay loading
- // actually sendError updates the state? No, sendError sends an event.
- // But the requestIds gets cleared.
+ assertTrue(viewModel.radioConfigState.value.responseState is ResponseState.Error)
}
@Test
diff --git a/feature/widget/build.gradle.kts b/feature/widget/build.gradle.kts
index 73c6fbc6d1..e7543ae7ff 100644
--- a/feature/widget/build.gradle.kts
+++ b/feature/widget/build.gradle.kts
@@ -52,6 +52,7 @@ dependencies {
testImplementation(libs.androidx.glance.appwidget.testing)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
// Robolectric's runner is JUnit 4; configureTestOptions() turns on useJUnitPlatform() for this
// task, so the vintage engine is what actually discovers and runs these tests.
diff --git a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
index 0bc269eaf4..cbe2e7df97 100644
--- a/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
+++ b/feature/widget/src/main/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsAction.kt
@@ -25,7 +25,29 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.CommandSender
+import org.meshtastic.core.repository.LocalNodeUnavailableException
import org.meshtastic.core.repository.NodeManager
+import org.meshtastic.core.repository.PacketQueueRejectedException
+
+internal suspend fun runTelemetryRequestBestEffort(type: TelemetryType, request: suspend () -> Unit) {
+ try {
+ request()
+ } catch (e: PacketQueueRejectedException) {
+ Logger.w(e) { "RefreshLocalStatsAction: $type request rejected by packet queue" }
+ } catch (e: LocalNodeUnavailableException) {
+ Logger.w(e) { "RefreshLocalStatsAction: $type request skipped because the local node became unavailable" }
+ }
+}
+
+internal suspend fun requestLocalStatsRefresh(
+ myNodeNum: Int?,
+ request: suspend (nodeNum: Int, type: TelemetryType) -> Unit,
+) {
+ if (myNodeNum == null) return
+ listOf(TelemetryType.LOCAL_STATS, TelemetryType.DEVICE).forEach { type ->
+ runTelemetryRequestBestEffort(type) { request(myNodeNum, type) }
+ }
+}
class RefreshLocalStatsAction :
ActionCallback,
@@ -41,7 +63,8 @@ class RefreshLocalStatsAction :
return
}
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
- commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.DEVICE.ordinal)
+ requestLocalStatsRefresh(myNodeNum) { nodeNum, type ->
+ commandSender.requestTelemetry(commandSender.generatePacketId(), nodeNum, type.ordinal)
+ }
}
}
diff --git a/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsActionTest.kt b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsActionTest.kt
new file mode 100644
index 0000000000..bc16b42c4f
--- /dev/null
+++ b/feature/widget/src/test/kotlin/org/meshtastic/feature/widget/RefreshLocalStatsActionTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.widget
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.repository.LocalNodeUnavailableException
+import org.meshtastic.core.repository.PacketQueueRejectedException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+class RefreshLocalStatsActionTest {
+
+ @Test
+ fun `refresh issues no request when the node number is unknown`() = runTest {
+ val requests = mutableListOf<Pair<Int, TelemetryType>>()
+
+ requestLocalStatsRefresh(myNodeNum = null) { nodeNum, type -> requests += nodeNum to type }
+
+ assertEquals(emptyList(), requests)
+ }
+
+ @Test
+ fun `refresh attempts both telemetry requests after the first is rejected`() = runTest {
+ val requests = mutableListOf<Pair<Int, TelemetryType>>()
+
+ requestLocalStatsRefresh(myNodeNum = 123) { nodeNum, type ->
+ requests += nodeNum to type
+ if (type == TelemetryType.LOCAL_STATS) throw PacketQueueRejectedException("queue closed")
+ }
+
+ assertEquals(listOf(123 to TelemetryType.LOCAL_STATS, 123 to TelemetryType.DEVICE), requests)
+ }
+
+ @Test
+ fun `local node loss during request is handled as best effort`() = runTest {
+ var attempts = 0
+
+ runTelemetryRequestBestEffort(TelemetryType.LOCAL_STATS) {
+ attempts++
+ throw LocalNodeUnavailableException("Widget telemetry refresh")
+ }
+
+ assertEquals(1, attempts)
+ }
+
+ @Test
+ fun `queue rejection during request is handled as best effort`() = runTest {
+ var attempts = 0
+
+ runTelemetryRequestBestEffort(TelemetryType.DEVICE) {
+ attempts++
+ throw PacketQueueRejectedException("queue closed")
+ }
+
+ assertEquals(1, attempts)
+ }
+
+ @Test
+ fun `unexpected failures are not swallowed`() = runTest {
+ assertFailsWith<IllegalStateException> {
+ runTelemetryRequestBestEffort(TelemetryType.DEVICE) { throw IllegalStateException("unexpected") }
+ }
+ }
+
+ @Test
+ fun `cancellation is not swallowed`() = runTest {
+ assertFailsWith<CancellationException> {
+ runTelemetryRequestBestEffort(TelemetryType.DEVICE) { throw CancellationException("cancelled") }
+ }
+ }
+
+ @Test
+ fun `successful request runs exactly once`() = runTest {
+ var attempts = 0
+
+ runTelemetryRequestBestEffort(TelemetryType.LOCAL_STATS) { attempts++ }
+
+ assertEquals(1, attempts)
+ }
+}
Served by rngit 1.5.2 - Generated in 2.14s